//go:build e2e package e2e import ( "encoding/json" "net/http" "testing" "github.com/stretchr/testify/require" ) // TestJourney_OwnerOnboarding 模擬 Tenant Owner 入職第一天會走的完整流程: // 已 login(seed 提供 JWT)→ 看自己 → 更新 profile → 驗證業務 email → 驗證 phone // → 綁定 TOTP → step-up 驗碼 → 解除 TOTP → logout。 // // 是「狀態流」測試:每步都用上一步的結果(challenge_id / display_name / OTP code), // 任一步 fail 後續自動 skip,便於一眼定位斷點。 func TestJourney_OwnerOnboarding(t *testing.T) { j := NewJourney(t, "J-1", "Tenant Owner 入職第一天(已登入後完整 onboarding)") defer j.Summary() c := NewClient(t) var ( emailTarget = "owner-journey@example.com" phoneTarget = "+886900111222" emailChallenge string phoneChallenge string otpauthURL string totpDigits int totpPeriod int totpCode string ) j.Step("1", "GET /me — 用 seed JWT 確認自己是 tenant_owner 且 status=active", func(t *testing.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.UID, me.UID) require.Equal(t, "active", me.Status) }) j.Step("2", "PATCH /me — 更新 display_name", func(t *testing.T) { env := c.DoExpectOK(t, http.MethodPatch, "/api/v1/members/me", map[string]string{ "display_name": "Journey Owner", }, true) var me struct { DisplayName string `json:"display_name"` } require.NoError(t, json.Unmarshal(env.Data, &me)) require.Equal(t, "Journey Owner", me.DisplayName) }) j.Step("3", "POST /me/verifications/email/start — 申請業務 email OTP", func(t *testing.T) { env := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/email/start", map[string]string{ "target": emailTarget, }, true) var start struct { ChallengeID string `json:"challenge_id"` } require.NoError(t, json.Unmarshal(env.Data, &start)) require.NotEmpty(t, start.ChallengeID) emailChallenge = start.ChallengeID }) j.Step("4", "POST /me/verifications/email/confirm — 從 Redis 取碼後驗證", func(t *testing.T) { require.NotEmpty(t, emailChallenge, "missing email challenge from step 3") code := FetchE2EOTP(t, emailChallenge) c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/email/confirm", map[string]string{ "challenge_id": emailChallenge, "code": code, }, true) }) j.Step("5", "GET /me — 確認 business_email_verified=true", func(t *testing.T) { 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, emailTarget, me.BusinessEmail) require.True(t, me.BusinessEmailVerified, "expected email_verified=true after confirm") }) j.Step("6", "POST /me/verifications/phone/start — 申請業務 phone OTP", func(t *testing.T) { env := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/phone/start", map[string]string{ "target": phoneTarget, }, true) var start struct { ChallengeID string `json:"challenge_id"` } require.NoError(t, json.Unmarshal(env.Data, &start)) phoneChallenge = start.ChallengeID }) j.Step("7", "POST /me/verifications/phone/confirm — 取碼後驗證", func(t *testing.T) { require.NotEmpty(t, phoneChallenge) code := FetchE2EOTP(t, phoneChallenge) c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/phone/confirm", map[string]string{ "challenge_id": phoneChallenge, "code": code, }, true) }) j.Step("8", "POST /me/totp/enroll-start — 開始綁定 TOTP", func(t *testing.T) { env := 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(env.Data, &start)) require.NotEmpty(t, start.OtpauthURL) otpauthURL = start.OtpauthURL totpDigits = start.Digits totpPeriod = start.PeriodSec }) j.Step("9", "POST /me/totp/enroll-confirm — 用 otpauth_url 算當下 TOTP code 確認綁定", func(t *testing.T) { require.NotEmpty(t, otpauthURL) totpCode = codeFromOtpauthURL(t, otpauthURL, totpDigits, totpPeriod) env := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/enroll-confirm", map[string]string{ "code": totpCode, }, true) var confirmed struct { BackupCodes []string `json:"backup_codes"` } require.NoError(t, json.Unmarshal(env.Data, &confirmed)) require.NotEmpty(t, confirmed.BackupCodes, "enroll-confirm should return backup codes exactly once") }) j.Step("10", "POST /me/totp/verify — step-up 驗碼成功", func(t *testing.T) { require.NotEmpty(t, totpCode) c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/verify", map[string]string{ "code": totpCode, }, true) }) j.Step("11", "POST /me/totp/verify (replay) — 同 code 再打應該 403(重放保護)", func(t *testing.T) { require.NotEmpty(t, totpCode) env := c.DoExpectHTTP(t, http.MethodPost, "/api/v1/members/me/totp/verify", map[string]string{ "code": totpCode, }, true, http.StatusForbidden) require.NotEqual(t, int64(successCode), env.Code) }) j.Step("12", "DELETE /me/totp — 解除綁定", func(t *testing.T) { c.DoExpectOK(t, http.MethodDelete, "/api/v1/members/me/totp", nil, true) 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) }) }