//go:build e2e package e2e import ( "encoding/json" "net/http" "net/url" "strconv" "testing" "time" membertotp "gateway/internal/model/member/totp" "github.com/stretchr/testify/require" ) func TestMember_GetMe(t *testing.T) { e2eStep(t, "M-01", "GET", "/api/v1/members/me", "讀 profile(tenant/uid/status)") c := NewClient(t) env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true) var me struct { TenantID string `json:"tenant_id"` UID string `json:"uid"` Status string `json:"status"` } require.NoError(t, json.Unmarshal(env.Data, &me)) require.Equal(t, c.Fixture.TenantID, me.TenantID) require.Equal(t, c.Fixture.UID, me.UID) require.Equal(t, "active", me.Status) } func TestMember_UpdateMe(t *testing.T) { e2eStep(t, "M-02", "PATCH", "/api/v1/members/me", "更新 display_name") c := NewClient(t) name := "E2E Updated Name" env := c.DoExpectOK(t, http.MethodPatch, "/api/v1/members/me", map[string]string{ "display_name": name, }, true) var me struct { DisplayName string `json:"display_name"` } require.NoError(t, json.Unmarshal(env.Data, &me)) require.Equal(t, name, me.DisplayName) } func TestMember_EmailVerification_FullFlow(t *testing.T) { e2eStep(t, "M-03/M-04", "POST", "/me/verifications/email/{start,confirm}", "業務 email OTP 申請 → 從 Redis 取碼 → 驗證 → email_verified=true") c := NewClient(t) target := "verified-e2e@example.com" startEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/email/start", map[string]string{ "target": target, }, true) var start struct { ChallengeID string `json:"challenge_id"` ExpiresIn int `json:"expires_in"` } require.NoError(t, json.Unmarshal(startEnv.Data, &start)) require.NotEmpty(t, start.ChallengeID) require.Positive(t, start.ExpiresIn) code := FetchE2EOTP(t, start.ChallengeID) c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/email/confirm", map[string]string{ "challenge_id": start.ChallengeID, "code": code, }, true) env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true) var me struct { BusinessEmail string `json:"business_email"` BusinessEmailVerified bool `json:"business_email_verified"` } require.NoError(t, json.Unmarshal(env.Data, &me)) require.Equal(t, target, me.BusinessEmail) require.True(t, me.BusinessEmailVerified) } func TestMember_PhoneVerification_FullFlow(t *testing.T) { e2eStep(t, "M-05/M-06", "POST", "/me/verifications/phone/{start,confirm}", "業務 phone OTP 申請 → 從 Redis 取碼 → 驗證 → phone_verified=true") c := NewClient(t) target := "+886912345678" startEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/phone/start", map[string]string{ "target": target, }, true) var start struct { ChallengeID string `json:"challenge_id"` ExpiresIn int `json:"expires_in"` } require.NoError(t, json.Unmarshal(startEnv.Data, &start)) require.NotEmpty(t, start.ChallengeID) require.Positive(t, start.ExpiresIn) code := FetchE2EOTP(t, start.ChallengeID) c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/phone/confirm", map[string]string{ "challenge_id": start.ChallengeID, "code": code, }, true) env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true) var me struct { BusinessPhone string `json:"business_phone"` BusinessPhoneVerified bool `json:"business_phone_verified"` } require.NoError(t, json.Unmarshal(env.Data, &me)) require.Equal(t, target, me.BusinessPhone) require.True(t, me.BusinessPhoneVerified) } func TestMember_TOTP_Status(t *testing.T) { e2eStep(t, "M-07", "GET", "/api/v1/members/me/totp", "查 TOTP 狀態(初始 enrolled=false)") c := NewClient(t) env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me/totp", nil, true) var st struct { Enrolled bool `json:"enrolled"` } require.NoError(t, json.Unmarshal(env.Data, &st)) require.False(t, st.Enrolled) } func TestMember_TOTP_FullFlow(t *testing.T) { e2eStep(t, "M-08~M-12", "POST", "/me/totp/*", "TOTP 全鏈路:enroll-start → confirm → verify → replay 403 → backup-codes → DELETE") c := NewClient(t) startEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/enroll-start", nil, true) var start struct { OtpauthURL string `json:"otpauth_url"` Digits int `json:"digits"` PeriodSec int `json:"period_seconds"` } require.NoError(t, json.Unmarshal(startEnv.Data, &start)) require.NotEmpty(t, start.OtpauthURL) require.Positive(t, start.Digits) require.Positive(t, start.PeriodSec) code := codeFromOtpauthURL(t, start.OtpauthURL, start.Digits, start.PeriodSec) confirmEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/enroll-confirm", map[string]string{ "code": code, }, true) var confirmed struct { BackupCodes []string `json:"backup_codes"` } require.NoError(t, json.Unmarshal(confirmEnv.Data, &confirmed)) require.NotEmpty(t, confirmed.BackupCodes) statusEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me/totp", nil, true) var status struct { Enrolled bool `json:"enrolled"` BackupCodesRemaining int `json:"backup_codes_remaining"` } require.NoError(t, json.Unmarshal(statusEnv.Data, &status)) require.True(t, status.Enrolled) require.Equal(t, len(confirmed.BackupCodes), status.BackupCodesRemaining) c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/verify", map[string]string{ "code": code, }, true) replayEnv := c.DoExpectHTTP(t, http.MethodPost, "/api/v1/members/me/totp/verify", map[string]string{ "code": code, }, true, http.StatusForbidden) require.NotEqual(t, int64(successCode), replayEnv.Code) backupEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/backup-codes", nil, true) var backup struct { BackupCodes []string `json:"backup_codes"` } require.NoError(t, json.Unmarshal(backupEnv.Data, &backup)) require.NotEmpty(t, backup.BackupCodes) c.DoExpectOK(t, http.MethodDelete, "/api/v1/members/me/totp", nil, true) finalEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me/totp", nil, true) var finalStatus struct { Enrolled bool `json:"enrolled"` } require.NoError(t, json.Unmarshal(finalEnv.Data, &finalStatus)) require.False(t, finalStatus.Enrolled) } func codeFromOtpauthURL(t *testing.T, rawURL string, digits, periodSec int) string { t.Helper() u, err := url.Parse(rawURL) require.NoError(t, err) require.Equal(t, "otpauth", u.Scheme) require.Equal(t, "totp", u.Host) q := u.Query() secret, err := membertotp.DecodeSecret(q.Get("secret")) require.NoError(t, err) if digits <= 0 { digits, _ = strconv.Atoi(q.Get("digits")) } if periodSec <= 0 { periodSec, _ = strconv.Atoi(q.Get("period")) } code, err := membertotp.Generate(secret, time.Now(), time.Duration(periodSec)*time.Second, digits) require.NoError(t, err) return code }