201 lines
6.8 KiB
Go
201 lines
6.8 KiB
Go
//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
|
||
}
|