yao/openapi/tests/otp/otp_test.go

590 lines
17 KiB
Go

package otp_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/openapi/otp"
"github.com/yaoapp/yao/openapi/tests/testutils"
)
type otpTestContext struct {
UserID string
TeamID string
MemberID string
Token string // access token with team context
ClientID string
Scope string
}
// setupTestData creates a real user, team type, team, and member for OTP tests.
// Returns an otpTestContext and a cleanup function.
func setupTestData(t *testing.T, serverURL string) (*otpTestContext, func()) {
t.Helper()
provider := testutils.GetUserProvider(t)
ctx := context.Background()
client := testutils.RegisterTestClient(t, "OTP Test Client", []string{"https://localhost/callback"})
tokenInfo := testutils.ObtainAccessTokenWithRootPermission(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Step 1: Create a team type
teamTypeID := fmt.Sprintf("otp_team_type_%d", time.Now().UnixNano())
_, err := provider.CreateType(ctx, map[string]interface{}{
"type_id": teamTypeID,
"name": "OTP Test Team Type",
"locale": "en-US",
"description": "Team type for OTP tests",
"is_active": true,
"created_at": time.Now(),
"updated_at": time.Now(),
})
require.NoError(t, err, "Failed to create team type")
// Step 2: Create a team with role_id (required for ACL)
teamID := fmt.Sprintf("otp_test_team_%d", time.Now().UnixNano())
_, err = provider.CreateTeam(ctx, map[string]interface{}{
"team_id": teamID,
"name": "OTP Test Team",
"description": "Team for OTP integration tests",
"owner_id": tokenInfo.UserID,
"type_id": teamTypeID,
"role_id": "system:root",
"status": "active",
"is_verified": true,
"created_at": time.Now(),
"updated_at": time.Now(),
})
require.NoError(t, err, "Failed to create test team")
// Step 3: Add user as team member with system:root role
memberID, err := provider.CreateMember(ctx, map[string]interface{}{
"team_id": teamID,
"user_id": tokenInfo.UserID,
"member_type": "user",
"role_id": "system:root",
"is_owner": true,
"status": "active",
"joined_at": time.Now(),
"created_at": time.Now(),
"updated_at": time.Now(),
})
require.NoError(t, err, "Failed to create team member")
// Step 4: Get team details for extra claims
team, err := provider.GetTeamByMember(ctx, teamID, tokenInfo.UserID)
require.NoError(t, err)
// Step 5: Create access token with team context
oauthService := oauth.OAuth
require.NotNil(t, oauthService, "OAuth service not initialized")
subject, err := oauthService.Subject(client.ClientID, tokenInfo.UserID)
require.NoError(t, err)
extraClaims := map[string]interface{}{
"user_id": tokenInfo.UserID,
"team_id": teamID,
}
if tenantID, ok := team["tenant_id"].(string); ok && tenantID != "" {
extraClaims["tenant_id"] = tenantID
}
if ownerID, ok := team["owner_id"].(string); ok && ownerID != "" {
extraClaims["owner_id"] = ownerID
}
if typeID, ok := team["type_id"].(string); ok && typeID != "" {
extraClaims["type_id"] = typeID
}
scope := "openid profile email system:root"
accessToken, err := oauthService.MakeAccessToken(client.ClientID, scope, subject, 3600, extraClaims)
require.NoError(t, err)
cleanup := func() {
provider.DeleteTeam(ctx, teamID)
provider.DeleteType(ctx, teamTypeID)
testutils.CleanupTestClient(t, client.ClientID)
}
return &otpTestContext{
UserID: tokenInfo.UserID,
TeamID: teamID,
MemberID: memberID,
Token: accessToken,
ClientID: client.ClientID,
Scope: scope,
}, cleanup
}
// ---------- POST /otp/login (public) ----------
func TestOTPLoginInvalidCode(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
body, _ := json.Marshal(map[string]string{"code": "nonexistent_code"})
resp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(body))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
assert.Equal(t, "invalid_otp", result["error"])
}
func TestOTPLoginMissingCode(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
body, _ := json.Marshal(map[string]string{})
resp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(body))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
func TestOTPLoginInvalidJSON(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
resp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBufferString("not json"))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
// ---------- POST /otp/create (disabled — HTTP endpoint removed for security) ----------
// Validation tests now covered by TestOTPServiceCreateValidation.
// ---------- Full flow: create (service) -> login (HTTP) ----------
func TestOTPCreateAndLogin(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
tc, cleanup := setupTestData(t, serverURL)
defer cleanup()
code, err := otp.OTP.Create(&otp.GenerateParams{
UserID: tc.UserID,
TeamID: tc.TeamID,
Redirect: "/test/dashboard",
Consume: true,
})
require.NoError(t, err)
assert.Len(t, code, 12)
// Login with the OTP code (public endpoint)
loginBody, _ := json.Marshal(map[string]string{"code": code, "locale": "en-US"})
loginResp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody))
require.NoError(t, err)
defer loginResp.Body.Close()
loginRaw, _ := io.ReadAll(loginResp.Body)
t.Logf("Login response: %d, body: %s", loginResp.StatusCode, string(loginRaw))
require.Equal(t, http.StatusOK, loginResp.StatusCode, "body: %s", string(loginRaw))
var loginResult map[string]interface{}
json.Unmarshal(loginRaw, &loginResult)
assert.Equal(t, "success", loginResult["status"])
assert.Equal(t, "/test/dashboard", loginResult["redirect"])
// Verify the code is consumed (default Consume=true)
loginBody2, _ := json.Marshal(map[string]string{"code": code})
loginResp2, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody2))
require.NoError(t, err)
defer loginResp2.Body.Close()
assert.Equal(t, http.StatusUnauthorized, loginResp2.StatusCode, "Code should be consumed after first login")
}
func TestOTPCreateAndLoginWithMemberID(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
tc, cleanup := setupTestData(t, serverURL)
defer cleanup()
code, err := otp.OTP.Create(&otp.GenerateParams{
TeamID: tc.TeamID,
MemberID: tc.MemberID,
Redirect: "/member-login-test",
Consume: true,
})
require.NoError(t, err)
payload, err := otp.OTP.Verify(code)
require.NoError(t, err)
assert.Equal(t, tc.MemberID, payload.MemberID)
assert.Equal(t, tc.TeamID, payload.TeamID)
assert.Equal(t, "", payload.UserID)
loginBody, _ := json.Marshal(map[string]string{"code": code, "locale": "en-US"})
loginResp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody))
require.NoError(t, err)
defer loginResp.Body.Close()
loginRaw, _ := io.ReadAll(loginResp.Body)
t.Logf("Login response: %d, body: %s", loginResp.StatusCode, string(loginRaw))
require.Equal(t, http.StatusOK, loginResp.StatusCode, "body: %s", string(loginRaw))
var loginResult map[string]interface{}
json.Unmarshal(loginRaw, &loginResult)
assert.Equal(t, "success", loginResult["status"])
assert.Equal(t, "/member-login-test", loginResult["redirect"])
}
func TestOTPCreateWithConsumeDisabled(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
tc, cleanup := setupTestData(t, serverURL)
defer cleanup()
code, err := otp.OTP.Create(&otp.GenerateParams{
UserID: tc.UserID,
TeamID: tc.TeamID,
Redirect: "/reusable",
Consume: false,
})
require.NoError(t, err)
// First login
loginBody, _ := json.Marshal(map[string]string{"code": code, "locale": "en-US"})
loginResp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody))
require.NoError(t, err)
defer loginResp.Body.Close()
require.Equal(t, http.StatusOK, loginResp.StatusCode)
// Second login should also work (Consume=false)
loginBody2, _ := json.Marshal(map[string]string{"code": code, "locale": "en-US"})
loginResp2, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody2))
require.NoError(t, err)
defer loginResp2.Body.Close()
assert.Equal(t, http.StatusOK, loginResp2.StatusCode, "Reusable OTP code should allow multiple logins")
}
func TestOTPCreateWithTokenExpiresIn(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL
code, err := otp.OTP.Create(&otp.GenerateParams{
UserID: "token_ttl_user",
Redirect: "/custom-ttl",
TokenExpiresIn: 600,
Consume: true,
})
require.NoError(t, err)
payload, err := otp.OTP.Verify(code)
require.NoError(t, err)
assert.Equal(t, 600, payload.TokenExpiresIn)
assert.Equal(t, "/custom-ttl", payload.Redirect)
assert.True(t, payload.Consume)
}
// ---------- Service-level tests (direct API) ----------
func TestOTPServiceCreateAndVerify(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL
code, err := otp.OTP.Create(&otp.GenerateParams{
UserID: "test_user_direct",
TeamID: "test_team_direct",
Redirect: "/direct-test",
Consume: true,
})
require.NoError(t, err)
assert.Len(t, code, 12)
payload, err := otp.OTP.Verify(code)
require.NoError(t, err)
assert.Equal(t, "test_user_direct", payload.UserID)
assert.Equal(t, "test_team_direct", payload.TeamID)
assert.Equal(t, "/direct-test", payload.Redirect)
assert.True(t, payload.Consume)
assert.Equal(t, 0, payload.TokenExpiresIn)
}
func TestOTPServiceCreateAndVerifyWithMemberID(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL
code, err := otp.OTP.Create(&otp.GenerateParams{
TeamID: "team_member_test",
MemberID: "member_12345",
Redirect: "/member-redirect",
Scope: "read:data",
TokenExpiresIn: 900,
Consume: false,
})
require.NoError(t, err)
assert.Len(t, code, 12)
payload, err := otp.OTP.Verify(code)
require.NoError(t, err)
assert.Equal(t, "", payload.UserID)
assert.Equal(t, "team_member_test", payload.TeamID)
assert.Equal(t, "member_12345", payload.MemberID)
assert.Equal(t, "/member-redirect", payload.Redirect)
assert.Equal(t, "read:data", payload.Scope)
assert.Equal(t, 900, payload.TokenExpiresIn)
assert.False(t, payload.Consume)
}
func TestOTPServiceCreateStoresMapPayload(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL
code, err := otp.OTP.Create(&otp.GenerateParams{
TeamID: "map_team",
MemberID: "map_member",
Redirect: "$dashboard/assistants",
Scope: "openid profile",
TokenExpiresIn: 600,
Consume: true,
})
require.NoError(t, err)
payload, err := otp.OTP.Verify(code)
require.NoError(t, err)
assert.Equal(t, "map_team", payload.TeamID)
assert.Equal(t, "map_member", payload.MemberID)
assert.Equal(t, "$dashboard/assistants", payload.Redirect)
assert.Equal(t, "openid profile", payload.Scope)
assert.Equal(t, 600, payload.TokenExpiresIn)
assert.True(t, payload.Consume)
}
func TestOTPServiceVerifyEmptyCode(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL
_, err := otp.OTP.Verify("")
assert.Error(t, err)
assert.Contains(t, err.Error(), "code is required")
}
func TestOTPServiceVerifyNonexistent(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL
_, err := otp.OTP.Verify("doesnotexist1")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid or expired")
}
func TestOTPServiceRevoke(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL
code, err := otp.OTP.Create(&otp.GenerateParams{
UserID: "revoke_user",
Redirect: "/revoke-test",
Consume: true,
})
require.NoError(t, err)
_, err = otp.OTP.Verify(code)
require.NoError(t, err)
err = otp.OTP.Revoke(code)
require.NoError(t, err)
_, err = otp.OTP.Verify(code)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid or expired")
}
func TestOTPServiceRevokeEmpty(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL
err := otp.OTP.Revoke("")
assert.NoError(t, err, "Revoking empty code should be silent")
}
func TestOTPServiceCreateValidation(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL
tests := []struct {
name string
params *otp.GenerateParams
errMsg string
}{
{"nil params", nil, "params is required"},
{"missing user and member", &otp.GenerateParams{Redirect: "/test"}, "user_id or member_id is required"},
{"missing redirect", &otp.GenerateParams{UserID: "u1"}, "redirect is required"},
{"member_id without team_id", &otp.GenerateParams{MemberID: "m1", Redirect: "/test"}, "team_id is required"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := otp.OTP.Create(tt.params)
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errMsg)
})
}
}
func TestOTPServiceCreateWithScope(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL
code, err := otp.OTP.Create(&otp.GenerateParams{
UserID: "scope_user",
TeamID: "scope_team",
Redirect: "/scoped",
Scope: "read:data write:data",
Consume: false,
})
require.NoError(t, err)
payload, err := otp.OTP.Verify(code)
require.NoError(t, err)
assert.Equal(t, "read:data write:data", payload.Scope)
assert.False(t, payload.Consume)
}
func TestOTPServiceCreateWithCustomExpiry(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL
code, err := otp.OTP.Create(&otp.GenerateParams{
UserID: "expiry_user",
Redirect: "/custom-expiry",
ExpiresIn: 60,
TokenExpiresIn: 300,
Consume: true,
})
require.NoError(t, err)
payload, err := otp.OTP.Verify(code)
require.NoError(t, err)
assert.Equal(t, 300, payload.TokenExpiresIn)
}
// ---------- Cross-team validation ----------
// NOTE: Cross-team HTTP endpoint test removed — /otp/create is disabled.
// Server-side Process callers are trusted and should validate team membership themselves.
// ---------- OTP Login sets cookies (no refresh token) ----------
func TestOTPLoginSetsAccessTokenCookieOnly(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
tc, cleanup := setupTestData(t, serverURL)
defer cleanup()
code, err := otp.OTP.Create(&otp.GenerateParams{
UserID: tc.UserID,
TeamID: tc.TeamID,
Redirect: "/cookie-test",
Consume: true,
})
require.NoError(t, err)
loginBody, _ := json.Marshal(map[string]string{"code": code})
loginResp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody))
require.NoError(t, err)
defer loginResp.Body.Close()
require.Equal(t, http.StatusOK, loginResp.StatusCode)
cookies := loginResp.Cookies()
hasAccessToken := false
hasRefreshToken := false
for _, c := range cookies {
t.Logf("Cookie: %s", c.Name)
if strings.HasSuffix(c.Name, "access_token") {
hasAccessToken = true
}
if strings.HasSuffix(c.Name, "refresh_token") {
hasRefreshToken = true
}
}
assert.True(t, hasAccessToken, "OTP login should set access_token cookie")
assert.False(t, hasRefreshToken, "OTP login should NOT set refresh_token cookie (SkipRefreshToken)")
}
// ---------- Code uniqueness ----------
func TestOTPCodeUniqueness(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL
codes := make(map[string]bool)
for i := 0; i < 50; i++ {
code, err := otp.OTP.Create(&otp.GenerateParams{
UserID: fmt.Sprintf("unique_user_%d", i),
Redirect: "/unique",
Consume: true,
})
require.NoError(t, err)
assert.False(t, codes[code], "Duplicate OTP code generated: %s", code)
codes[code] = true
}
assert.Len(t, codes, 50, "All 50 codes should be unique")
}