163 lines
5.9 KiB
Go
163 lines
5.9 KiB
Go
|
|
//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)
|
|||
|
|
})
|
|||
|
|
}
|