Files
Felis/internal/api/handlers_account_migrate_test.go
T

471 lines
23 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// Account-migration tests (spec §B3 inherit, scenario A) across BOTH faces. What they
// prove is the handler + state machine (initiated → confirmed → code_issued → redeemed)
// and the load-bearing security properties of the flow, against the fakeRepo and a fake
// PasskeyVerifier — not the pgrepo SQL nor the WebAuthn crypto (both mirrored here). The
// decisive properties, in flow order:
//
// - Step-up is a FRESH proof, and force-passkey is not downgradable: an account with a
// passkey cannot confirm by email (no OTP is even minted).
// - The one-time code is bound to the NAMED target at issue AND the redeemer must be
// that target, so an intercepted code is useless to anyone else.
// - The step-up is never weaker than the login door: a cloned authenticator the login
// door refuses is refused here too, and confirms nothing.
// - Redeem is a double-spend-safe, atomic transfer: the source's servers move to the
// target, the source is retired, and the code cannot be replayed.
// migrateEnv wires the migration doors over one shared fakeRepo: a capturing mailer (so a
// test can read the OTP that production only ever emails, and the notices) and a fake passkey verifier
// primed with fixed options + a verified assertion. mk builds a face for a given
// principal — the internal handler ignores it, each external handler is scoped to one
// caller — so a test can drive the source and the target (and an interloper) against the
// same store.
func migrateEnv(repo *fakeRepo) (mk func(*Principal) *API, mailer *noticeMailer, v *fakePasskeyVerifier) {
mailer = &noticeMailer{}
v = &fakePasskeyVerifier{
options: json.RawMessage(`{"publicKey":{"challenge":"bWlncmF0ZQ"}}`),
assertion: VerifiedAssertion{CredentialID: "cred-1", UserVerified: true},
}
cl := newFakeCluster()
mk = func(p *Principal) *API {
a := newTestAPI(repo, cl)
a.External = staticExternal{p: p}
a.Mailer = mailer
a.Passkey = v
return a
}
return mk, mailer, v
}
// startMigrate drives the in-game /felis migrate call (internal face) for a linked UUID.
func startMigrate(t *testing.T, ih http.Handler, mcUUID string) *httptest.ResponseRecorder {
t.Helper()
return do(ih, "POST", "/api/v1/internal/account/migrate/start", `{"mc_uuid":"`+mcUUID+`"}`, jsonHeader)
}
// TestMigrateVertical walks the whole slice: an in-game start, an email-OTP step-up
// (the source holds no passkey, so email is allowed), a code issued against a named
// target, and the target redeeming it — moving exactly the source's servers and retiring
// the source. The single-use property closes it: the spent code cannot be replayed.
func TestMigrateVertical(t *testing.T) {
const uuid = "11111111-1111-1111-1111-111111111111"
src := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
tgt := &Principal{UserID: "u2", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
repo.seedUser(UserView{ID: "u1", Username: "old", Email: "[email protected]", Role: "user"})
repo.seedUser(UserView{ID: "u2", Username: "new", Email: "[email protected]", Role: "user"})
repo.links[uuid] = "u1"
repo.byName["alpha"] = &ServerRecord{Name: "alpha", OwnerID: "u1"}
repo.byName["beta"] = &ServerRecord{Name: "beta", OwnerID: "u1"}
repo.byName["other"] = &ServerRecord{Name: "other", OwnerID: "u2"} // the target's own — must NOT move
mk, mailer, _ := migrateEnv(repo)
ih := mk(src).InternalHandler()
ehSrc := mk(src).ExternalHandler()
ehTgt := mk(tgt).ExternalHandler()
// 1) in-game start puts the linked account into migrate mode.
if w := startMigrate(t, ih, uuid); w.Code != http.StatusCreated {
t.Fatalf("start: code = %d, want 201 (%s)", w.Code, w.Body.String())
}
if w := do(ehSrc, "GET", "/api/v1/account/migrate", "", nil); acctBody(t, w)["state"] != "initiated" {
t.Fatalf("status after start = %s, want initiated", w.Body.String())
}
// 2) email-OTP step-up. The code is delivered out of band (captured here), never in
// the response, and verifying it advances the migration to confirmed.
w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/start", "", jsonHeader)
if w.Code != http.StatusAccepted {
t.Fatalf("otp start: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
if _, leaked := acctBody(t, w)["code"]; leaked {
t.Error("otp start response must NEVER carry the code")
}
code := mailer.code
if code == "" {
t.Fatal("no OTP delivered")
}
w = do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/verify", `{"code":"`+code+`"}`, jsonHeader)
if w.Code != http.StatusOK || acctBody(t, w)["confirmed"] != true {
t.Fatalf("otp verify: code = %d body %s, want 200 confirmed", w.Code, w.Body.String())
}
if b := do(ehSrc, "GET", "/api/v1/account/migrate", "", nil); acctBody(t, b)["confirm_factor"] != "email_otp" {
t.Fatalf("status after confirm = %s, want confirm_factor email_otp", b.Body.String())
}
// 3) the source names the target and mints a one-time code.
w = do(ehSrc, "POST", "/api/v1/account/migrate/issue-code", `{"target_user_id":"u2"}`, jsonHeader)
if w.Code != http.StatusCreated {
t.Fatalf("issue-code: code = %d, want 201 (%s)", w.Code, w.Body.String())
}
mcode, _ := acctBody(t, w)["code"].(string)
if mcode == "" {
t.Fatal("issue-code returned no code")
}
// 4) the target redeems: exactly the source's two servers move; the target's own is
// untouched; the source is retired.
w = do(ehTgt, "POST", "/api/v1/account/migrate/redeem", `{"code":"`+mcode+`"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("redeem: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
rb := acctBody(t, w)
if rb["migrated"] != true || rb["servers_moved"] != float64(2) {
t.Fatalf("redeem body = %v, want migrated:true servers_moved:2", rb)
}
if repo.byName["alpha"].OwnerID != "u2" || repo.byName["beta"].OwnerID != "u2" {
t.Fatalf("source servers not moved: alpha=%s beta=%s", repo.byName["alpha"].OwnerID, repo.byName["beta"].OwnerID)
}
if repo.byName["other"].OwnerID != "u2" { // was u2 already; a move would be a bug either way
t.Fatalf("target's own server changed owner to %s", repo.byName["other"].OwnerID)
}
if d, _ := repo.UserDetail(context.Background(), "u1"); d == nil || d.DeletedAt == nil || !d.Disabled {
t.Fatalf("source account not retired: %+v", d)
}
// 5) single-use: the spent code cannot be replayed.
if w := do(ehTgt, "POST", "/api/v1/account/migrate/redeem", `{"code":"`+mcode+`"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("replay of spent code: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String())
}
// 6) the source never proved its address, so no notice went there.
if len(mailer.notices) != 0 {
t.Fatalf("notices to an unverified address = %q, want none", mailer.notices)
}
}
// TestMigrateForcePasskey pins the contract's "若有 Passkey 强制 Passkey": an account with
// a passkey enrolled cannot step up by email — the OTP door refuses with passkey_required
// and mints NOTHING, so the strong factor is not downgradable for an identity transfer.
func TestMigrateForcePasskey(t *testing.T) {
const uuid = "22222222-2222-2222-2222-222222222222"
src := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
repo.seedUser(UserView{ID: "u1", Username: "old", Email: "[email protected]", Role: "user"})
repo.links[uuid] = "u1"
repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", UserVerified: true, CreatedAt: frozenNow}
mk, mailer, _ := migrateEnv(repo)
ih := mk(src).InternalHandler()
ehSrc := mk(src).ExternalHandler()
if w := startMigrate(t, ih, uuid); w.Code != http.StatusCreated {
t.Fatalf("start: code = %d, want 201 (%s)", w.Code, w.Body.String())
}
w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/start", "", jsonHeader)
if w.Code != http.StatusConflict || decodeErr(t, w) != "passkey_required" {
t.Fatalf("otp start with passkey enrolled: code = %d body %s, want 409 passkey_required", w.Code, w.Body.String())
}
if mailer.calls != 0 {
t.Fatalf("an OTP was minted despite an enrolled passkey (calls=%d)", mailer.calls)
}
}
// TestMigratePasskeyConfirm drives the passkey step-up: begin stashes exactly one
// migrate-purpose challenge for the caller, and finish verifies the assertion against the
// SERVER-STASHED session data (the body carries no challenge) and advances the migration
// to confirmed with factor 'passkey'.
func TestMigratePasskeyConfirm(t *testing.T) {
const uuid = "33333333-3333-3333-3333-333333333333"
src := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
repo.seedUser(UserView{ID: "u1", Username: "old", Email: "[email protected]", Role: "user"})
repo.links[uuid] = "u1"
repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", UserVerified: true, CreatedAt: frozenNow}
mk, _, v := migrateEnv(repo)
ih := mk(src).InternalHandler()
ehSrc := mk(src).ExternalHandler()
if w := startMigrate(t, ih, uuid); w.Code != http.StatusCreated {
t.Fatalf("start: code = %d, want 201 (%s)", w.Code, w.Body.String())
}
// begin: exactly one migrate-purpose challenge stashed for u1, options returned verbatim.
w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/passkey/begin", "", jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("passkey begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if len(repo.passkeyChallenges) != 1 {
t.Fatalf("begin must stash exactly one challenge, got %d", len(repo.passkeyChallenges))
}
for _, c := range repo.passkeyChallenges {
if c.userID != "u1" || c.purpose != passkeyPurposeMigrate {
t.Errorf("stashed challenge = %+v, want user u1 purpose %q", c, passkeyPurposeMigrate)
}
}
// finish: the body carries NO challenge; the stashed blob reaches the verifier only via
// store-stash → consume, and a verified assertion confirms the migration.
w = do(ehSrc, "POST", "/api/v1/account/migrate/confirm/passkey/finish",
`{"assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
if w.Code != http.StatusOK || acctBody(t, w)["confirmed"] != true {
t.Fatalf("passkey finish: code = %d body %s, want 200 confirmed", w.Code, w.Body.String())
}
if len(v.lastSession) == 0 {
t.Error("finish must feed the verifier the server-stashed session data")
}
if m, _ := repo.MigrationForSource(context.Background(), "u1"); m == nil || m.State != "confirmed" || m.ConfirmFactor != "passkey" {
t.Fatalf("migration after passkey confirm = %+v, want state confirmed factor passkey", m)
}
}
// TestMigratePasskeyCloneRejected proves the step-up is never weaker than the login door:
// a cloned authenticator (rolled-back counter) is refused with the opaque envelope and
// confirms nothing — the migration stays in initiated.
func TestMigratePasskeyCloneRejected(t *testing.T) {
const uuid = "44444444-4444-4444-4444-444444444444"
src := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
repo.seedUser(UserView{ID: "u1", Username: "old", Email: "[email protected]", Role: "user"})
repo.links[uuid] = "u1"
repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", UserVerified: true, CreatedAt: frozenNow}
mk, _, v := migrateEnv(repo)
v.assertion = VerifiedAssertion{CredentialID: "cred-1", UserVerified: true, CloneWarning: true}
ih := mk(src).InternalHandler()
ehSrc := mk(src).ExternalHandler()
if w := startMigrate(t, ih, uuid); w.Code != http.StatusCreated {
t.Fatalf("start: code = %d, want 201 (%s)", w.Code, w.Body.String())
}
if w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/passkey/begin", "", jsonHeader); w.Code != http.StatusOK {
t.Fatalf("passkey begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/passkey/finish",
`{"assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" {
t.Fatalf("cloned authenticator: code = %d body %s, want 400 passkey_login_invalid", w.Code, w.Body.String())
}
if m, _ := repo.MigrationForSource(context.Background(), "u1"); m == nil || m.State != "initiated" {
t.Fatalf("migration after clone-rejected finish = %+v, want still initiated", m)
}
}
// TestMigrateRedeemBinding proves the one-time code is bound to the NAMED target: an
// interloper who holds the correct code but is a different account cannot redeem it (it
// simply does not match), the transfer does not happen, and the code survives for the
// genuine target to spend.
func TestMigrateRedeemBinding(t *testing.T) {
const uuid = "55555555-5555-5555-5555-555555555555"
src := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
tgt := &Principal{UserID: "u2", Email: "[email protected]", Role: "user"}
interloper := &Principal{UserID: "u3", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
repo.seedUser(UserView{ID: "u1", Username: "old", Email: "[email protected]", Role: "user"})
repo.seedUser(UserView{ID: "u2", Username: "new", Email: "[email protected]", Role: "user"})
repo.links[uuid] = "u1"
repo.byName["alpha"] = &ServerRecord{Name: "alpha", OwnerID: "u1"}
mk, mailer, _ := migrateEnv(repo)
ih := mk(src).InternalHandler()
ehSrc := mk(src).ExternalHandler()
// Drive to a code issued against u2.
if w := startMigrate(t, ih, uuid); w.Code != http.StatusCreated {
t.Fatalf("start: %d (%s)", w.Code, w.Body.String())
}
if w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/start", "", jsonHeader); w.Code != http.StatusAccepted {
t.Fatalf("otp start: %d (%s)", w.Code, w.Body.String())
}
if w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/verify", `{"code":"`+mailer.code+`"}`, jsonHeader); w.Code != http.StatusOK {
t.Fatalf("otp verify: %d (%s)", w.Code, w.Body.String())
}
w := do(ehSrc, "POST", "/api/v1/account/migrate/issue-code", `{"target_user_id":"u2"}`, jsonHeader)
if w.Code != http.StatusCreated {
t.Fatalf("issue-code: %d (%s)", w.Code, w.Body.String())
}
mcode, _ := acctBody(t, w)["code"].(string)
// The interloper holds the correct code but is not the named target: no match.
ehEvil := mk(interloper).ExternalHandler()
if w := do(ehEvil, "POST", "/api/v1/account/migrate/redeem", `{"code":"`+mcode+`"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("interloper redeem: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String())
}
if repo.byName["alpha"].OwnerID != "u1" {
t.Fatalf("server moved on a non-target redeem: owner=%s", repo.byName["alpha"].OwnerID)
}
if d, _ := repo.UserDetail(context.Background(), "u1"); d.DeletedAt != nil {
t.Fatal("source retired on a non-target redeem")
}
// The genuine target still spends the same code — the failed attempt consumed nothing.
ehTgt := mk(tgt).ExternalHandler()
if w := do(ehTgt, "POST", "/api/v1/account/migrate/redeem", `{"code":"`+mcode+`"}`, jsonHeader); w.Code != http.StatusOK {
t.Fatalf("genuine target redeem: code = %d body %s, want 200", w.Code, w.Body.String())
}
if repo.byName["alpha"].OwnerID != "u2" {
t.Fatalf("server not moved to genuine target: owner=%s", repo.byName["alpha"].OwnerID)
}
}
// TestMigrateGuards covers the input/state refusals: an unlinked UUID has no account to
// migrate; a code cannot be issued before confirmation; the target may be neither the
// source itself nor an unknown account.
func TestMigrateGuards(t *testing.T) {
const uuid = "66666666-6666-6666-6666-666666666666"
src := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
repo.seedUser(UserView{ID: "u1", Username: "old", Email: "[email protected]", Role: "user"})
repo.links[uuid] = "u1"
mk, mailer, _ := migrateEnv(repo)
ih := mk(src).InternalHandler()
ehSrc := mk(src).ExternalHandler()
// start on an unlinked UUID → 404 not_linked.
if w := startMigrate(t, ih, "00000000-0000-0000-0000-000000000000"); w.Code != http.StatusNotFound || decodeErr(t, w) != "not_linked" {
t.Fatalf("start unlinked: code = %d body %s, want 404 not_linked", w.Code, w.Body.String())
}
// A real start, then issue-code BEFORE confirming → 409 not_confirmed.
if w := startMigrate(t, ih, uuid); w.Code != http.StatusCreated {
t.Fatalf("start: %d (%s)", w.Code, w.Body.String())
}
if w := do(ehSrc, "POST", "/api/v1/account/migrate/issue-code", `{"target_user_id":"u1"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_target" {
t.Fatalf("issue with target==source: code = %d body %s, want 400 invalid_target", w.Code, w.Body.String())
}
// Confirm (email; no passkey on this account), then the target guards apply.
if w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/start", "", jsonHeader); w.Code != http.StatusAccepted {
t.Fatalf("otp start: %d (%s)", w.Code, w.Body.String())
}
if w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/verify", `{"code":"`+mailer.code+`"}`, jsonHeader); w.Code != http.StatusOK {
t.Fatalf("otp verify: %d (%s)", w.Code, w.Body.String())
}
if w := do(ehSrc, "POST", "/api/v1/account/migrate/issue-code", `{"target_user_id":"ghost"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "target_not_found" {
t.Fatalf("issue with unknown target: code = %d body %s, want 400 target_not_found", w.Code, w.Body.String())
}
}
// TestMigrateConfirmHoldsForOneSessionBriefly pins who may issue the code: only the
// browser session that gave the step-up, and only for migrateConfirmWindow. Any other
// live session of the source, or the same one later, meets the step-up again; a code
// that expires unspent does too. The source's mailbox hears about each code and about
// the redeem.
func TestMigrateConfirmHoldsForOneSessionBriefly(t *testing.T) {
const uuid = "77777777-7777-7777-7777-777777777777"
src := &Principal{UserID: "u1", Username: "old", Email: "[email protected]", EmailVerified: true, Role: "user", ViaSession: true}
tgt := &Principal{UserID: "u2", Username: "new", Email: "[email protected]", EmailVerified: true, Role: "user", ViaSession: true}
repo := newFakeRepo()
repo.seedUser(UserView{ID: "u1", Username: "old", Email: "[email protected]", EmailVerified: true, Role: "user"})
repo.seedUser(UserView{ID: "u2", Username: "new", Email: "[email protected]", EmailVerified: true, Role: "user"})
repo.links[uuid] = "u1"
repo.byName["alpha"] = &ServerRecord{Name: "alpha", OwnerID: "u1"}
repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", UserVerified: true, CreatedAt: frozenNow}
mk, mail, _ := migrateEnv(repo)
now := time.Unix(1_700_000_000, 0)
external := func(p *Principal) http.Handler {
a := mk(p)
a.Now = func() time.Time { return now }
return a.ExternalHandler()
}
ehSrc, ehTgt := external(src), external(tgt)
onA := map[string]string{"Content-Type": "application/json", "Cookie": sessionCookieName + "=cookie-a"}
onB := map[string]string{"Content-Type": "application/json", "Cookie": sessionCookieName + "=cookie-b"}
confirm := func(h map[string]string) {
t.Helper()
if w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/passkey/begin", "", h); w.Code != http.StatusOK {
t.Fatalf("passkey begin: %d (%s)", w.Code, w.Body.String())
}
if w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/passkey/finish",
`{"assertion":{"id":"cred-1","type":"public-key"}}`, h); w.Code != http.StatusOK {
t.Fatalf("passkey finish: %d (%s)", w.Code, w.Body.String())
}
}
status := func(h map[string]string) map[string]any {
t.Helper()
return acctBody(t, do(ehSrc, "GET", "/api/v1/account/migrate", "", h))
}
issue := func(h map[string]string) *httptest.ResponseRecorder {
return do(ehSrc, "POST", "/api/v1/account/migrate/issue-code", `{"target_user_id":"u2"}`, h)
}
refused := func(what string, w *httptest.ResponseRecorder) {
t.Helper()
if w.Code != http.StatusConflict || decodeErr(t, w) != "not_confirmed" {
t.Fatalf("%s: code = %d body %s, want 409 not_confirmed", what, w.Code, w.Body.String())
}
}
if w := startMigrate(t, mk(src).InternalHandler(), uuid); w.Code != http.StatusCreated {
t.Fatalf("start: %d (%s)", w.Code, w.Body.String())
}
confirm(onA)
if st := status(onA); st["state"] != "confirmed" || st["confirm_expires_at"] != now.Add(migrateConfirmWindow).UTC().Format(time.RFC3339) {
t.Fatalf("status on a after its confirmation = %v, want confirmed until +%v", st, migrateConfirmWindow)
}
if st := status(onB); st["state"] != "initiated" {
t.Fatalf("status on b = %v, want initiated: b has not confirmed", st)
}
refused("issue from b", issue(onB))
refused("issue with no session", issue(jsonHeader))
now = now.Add(migrateConfirmWindow)
refused("issue from a once its window closed", issue(onA))
refused("issue to an unknown account from a once its window closed",
do(ehSrc, "POST", "/api/v1/account/migrate/issue-code", `{"target_user_id":"ghost"}`, onA))
if st := status(onA); st["state"] != "initiated" {
t.Fatalf("status on a after its window = %v, want initiated", st)
}
confirm(onA)
if len(mail.notices) != 0 {
t.Fatalf("notices before any code = %q, want none", mail.notices)
}
w := issue(onA)
if w.Code != http.StatusCreated {
t.Fatalf("issue from a inside its window: %d (%s)", w.Code, w.Body.String())
}
first, _ := acctBody(t, w)["code"].(string)
exp := now.Add(migrateCodeTTL).UTC().Format("2006-01-02 15:04 MST")
if len(mail.notices) != 1 || !strings.HasPrefix(mail.notices[0], "[email protected]|") ||
!strings.Contains(mail.notices[0], "「new」") || !strings.Contains(mail.notices[0], exp) ||
!strings.Contains(mail.notices[0], "/felis migrate") {
t.Fatalf("notices after the code = %q, want one to [email protected] naming new, %s and how to void it", mail.notices, exp)
}
if st := status(onA); st["state"] != "code_issued" || st["target_user_id"] != "u2" {
t.Fatalf("status after the code = %v, want code_issued for u2", st)
}
// The code expires unspent: back to the step-up, and the old code is dead.
now = now.Add(migrateCodeTTL)
if st := status(onA); st["state"] != "initiated" {
t.Fatalf("status after the code expired = %v, want initiated", st)
}
confirm(onA)
if w := do(ehTgt, "POST", "/api/v1/account/migrate/redeem", `{"code":"`+first+`"}`, jsonHeader); w.Code != http.StatusBadRequest {
t.Fatalf("redeem of the expired code: %d (%s), want 400", w.Code, w.Body.String())
}
w = issue(onA)
if w.Code != http.StatusCreated {
t.Fatalf("issue after the expired code: %d (%s)", w.Code, w.Body.String())
}
second, _ := acctBody(t, w)["code"].(string)
if w := do(ehTgt, "POST", "/api/v1/account/migrate/redeem", `{"code":"`+second+`"}`, jsonHeader); w.Code != http.StatusOK {
t.Fatalf("redeem: %d (%s)", w.Code, w.Body.String())
}
if n := len(mail.notices); n != 3 || !strings.HasPrefix(mail.notices[2], "[email protected]|") ||
!strings.Contains(mail.notices[2], "「new」") || !strings.Contains(mail.notices[2], "alpha") {
t.Fatalf("notices after the redeem = %q, want a third to [email protected] naming new and alpha", mail.notices)
}
}