From 11244138769e9f9a7087e19ce6946fd1abfa7794 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Sun, 27 Sep 2026 13:55:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(api):=20setup=20=E9=93=BE=E6=8E=A5=E5=9C=A8?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E4=BA=8B=E5=8A=A1=E9=87=8C=E6=B6=88=E8=B4=B9?= =?UTF-8?q?=E5=B9=B6=E5=BB=BA=E4=BC=9A=E8=AF=9D=EF=BC=8C=E5=AD=98=E5=82=A8?= =?UTF-8?q?=E6=95=85=E9=9A=9C=E5=9B=9E=20500=20=E4=B8=94=E9=93=BE=E6=8E=A5?= =?UTF-8?q?=E4=BB=8D=E5=8F=AF=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/openapi.yaml | 6 +- internal/api/api_test.go | 15 ++++- internal/api/handlers_setup.go | 24 +++++--- internal/api/handlers_setup_test.go | 53 ++++++++++++++++ internal/api/pgrepo.go | 37 ++++++++--- internal/api/repo.go | 12 ++-- internal/api/session.go | 27 +++++--- internal/pgint/accounts_test.go | 58 +++++++++++++++--- panel/src/i18n/resources/en-US/auth.json | 3 + panel/src/i18n/resources/zh-CN/auth.json | 3 + panel/src/lib/openapi.gen.ts | 3 +- panel/src/pages/Setup.test.tsx | 78 ++++++++++++++++++++++++ panel/src/pages/Setup.tsx | 40 ++++++++++-- 13 files changed, 312 insertions(+), 47 deletions(-) create mode 100644 panel/src/pages/Setup.test.tsx diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 721703e..6ea3201 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -3359,7 +3359,9 @@ paths: the felis TUI (stored and looked up by SHA-256 hash, like session cookies), mints a host-only felis_session, and returns the remaining setup steps so the SPA can drive the wizard. An unknown, consumed, or expired token returns a - uniform 400 setup_token_invalid. Gated on local_auth_enabled. + uniform 400 setup_token_invalid. Gated on local_auth_enabled. The token is + spent in the same transaction that stores the session, so a redemption that + fails with 500 leaves the link working for another try. x-felis-face: [external] x-felis-tier: public security: [] @@ -3407,6 +3409,8 @@ paths: schema: { $ref: '#/components/schemas/Error' } '429': $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/InternalError' /api/v1/auth/setup/status: get: diff --git a/internal/api/api_test.go b/internal/api/api_test.go index d7b80bb..8a0b0f7 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -94,6 +94,7 @@ type fakeRepo struct { failRevokeOthers error failMarkReauth error failGetSetting error + failRedeemSetup error // player email OTPs (spec §B2). Keyed by row id; the verify path scans for the // newest live (user, purpose) just as the PG query does. otps map[string]*fakeEmailOTP @@ -1779,15 +1780,23 @@ func (f *fakeRepo) ApproveOpLogin(_ context.Context, id, approverUserID string, return nil } -// ConsumeSetupToken atomically marks a one-time setup token consumed and returns -// its user_id, or ErrNotFound when absent, already consumed, or expired. -func (f *fakeRepo) ConsumeSetupToken(_ context.Context, tokenHash string, now time.Time) (string, error) { +// RedeemSetupToken spends a setup token and stores s for its user, or ErrNotFound +// when the token is absent, already spent, or expired. failRedeemSetup fails the +// whole redemption. +func (f *fakeRepo) RedeemSetupToken(ctx context.Context, tokenHash string, now time.Time, s NewSession) (string, error) { + if f.failRedeemSetup != nil { + return "", f.failRedeemSetup + } tok, ok := f.setupTokens[tokenHash] if !ok || !tok.ConsumedAt.IsZero() || !tok.ExpiresAt.After(now) { return "", ErrNotFound } tok.ConsumedAt = now f.setupTokens[tokenHash] = tok + s.UserID = tok.UserID + if err := f.CreateSession(ctx, s); err != nil { + return "", err + } return tok.UserID, nil } diff --git a/internal/api/handlers_setup.go b/internal/api/handlers_setup.go index 5820dae..ae96627 100644 --- a/internal/api/handlers_setup.go +++ b/internal/api/handlers_setup.go @@ -62,26 +62,32 @@ func (a *API) handleSetupRedeem(w http.ResponseWriter, r *http.Request) { sum := sha256.Sum256([]byte(token)) tokenHash := hex.EncodeToString(sum[:]) - now := a.now() - userID, err := a.Repo.ConsumeSetupToken(r.Context(), tokenHash, now) + // A regular felis_session; the lockdown is a product-level restriction the + // frontend enforces until email is verified / a passkey is bound. The token and + // the session are written together: a new setup link takes `felis setup` on + // the node, so a failure here must leave this one working. + sessionToken, session, err := a.mintSession(r, "", provenSignIn) if err != nil { + writeError(w, r, err) + return + } + userID, err := a.Repo.RedeemSetupToken(r.Context(), tokenHash, a.now(), session) + switch { + case errors.Is(err, ErrNotFound): // Unknown, already-consumed, or expired — uniform 400 so the token cannot // be used as an oracle. a.authFailure(r, "setup_redeem", "bad_token", nil) writeError(w, r, newError(http.StatusBadRequest, "setup_token_invalid", "this setup link is invalid or has already been used")) return - } - - u, err := a.Repo.UserByID(r.Context(), userID) - if err != nil { + case err != nil: writeError(w, r, err) return } + setSessionCookie(w, sessionToken, session.ExpiresAt) - // Mint the session — a regular felis_session; the lockdown is a product-level - // restriction the frontend enforces until email is verified / a passkey is bound. - if err := a.startSession(w, r, u.ID, provenSignIn); err != nil { + u, err := a.Repo.UserByID(r.Context(), userID) + if err != nil { writeError(w, r, err) return } diff --git a/internal/api/handlers_setup_test.go b/internal/api/handlers_setup_test.go index 793583a..6b72266 100644 --- a/internal/api/handlers_setup_test.go +++ b/internal/api/handlers_setup_test.go @@ -1,9 +1,13 @@ package api import ( + "crypto/sha256" + "encoding/hex" "encoding/json" + "errors" "net/http" "testing" + "time" ) // TestSetupNoSMTPFlow pins the no-SMTP onboarding contract: setup completes on email @@ -137,6 +141,55 @@ func TestSetupCompletesForNoEmailPlayer(t *testing.T) { } } +// Redeeming a setup link signs the owner in once. A link the store does not know +// (unknown, spent, expired) is the uniform 400; a store that fails is an outage, +// answered as one, so the page does not tell the owner a link that still works +// was used up. +func TestSetupRedeem(t *testing.T) { + const raw = "setup-token-raw" + sum := sha256.Sum256([]byte(raw)) + hash := hex.EncodeToString(sum[:]) + body := `{"token":"` + raw + `"}` + setup := func() (*fakeRepo, http.Handler) { + repo := newFakeRepo() + repo.settings[LocalAuthEnabledKey] = []byte("true") + repo.staff["owner"] = &StaffUser{ID: "o1", Username: "owner", Role: "admin"} + repo.setupTokens[hash] = fakeSetupToken{TokenHash: hash, UserID: "o1", + ExpiresAt: time.Unix(1_700_000_000, 0).Add(10 * time.Minute)} + return repo, newTestAPI(repo, newFakeCluster()).ExternalHandler() + } + + t.Run("redeems once", func(t *testing.T) { + repo, h := setup() + w := do(h, "POST", "/api/v1/auth/setup/redeem", body, jsonHeader) + if w.Code != http.StatusOK { + t.Fatalf("redeem: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + cookies := w.Result().Cookies() + if len(cookies) != 1 || cookies[0].Name != sessionCookieName { + t.Fatalf("cookies = %v, want one %s", cookies, sessionCookieName) + } + if s, ok := repo.sessions[hashCookie(cookies[0].Value)]; !ok || s.userID != "o1" { + t.Fatalf("session for the cookie = %+v (found %v), want one of o1", s, ok) + } + if w := do(h, "POST", "/api/v1/auth/setup/redeem", body, jsonHeader); w.Code != http.StatusBadRequest || + errCode(w.Body.Bytes()) != "setup_token_invalid" || len(w.Result().Cookies()) != 0 { + t.Fatalf("replay: code = %d err = %q cookies = %v, want 400 setup_token_invalid and none", + w.Code, errCode(w.Body.Bytes()), w.Result().Cookies()) + } + }) + + t.Run("store outage", func(t *testing.T) { + repo, h := setup() + repo.failRedeemSetup = errors.New("connection refused") + w := do(h, "POST", "/api/v1/auth/setup/redeem", body, jsonHeader) + if w.Code != http.StatusInternalServerError || errCode(w.Body.Bytes()) != "internal" || len(w.Result().Cookies()) != 0 { + t.Fatalf("outage: code = %d err = %q cookies = %v, want 500 internal and no cookie", + w.Code, errCode(w.Body.Bytes()), w.Result().Cookies()) + } + }) +} + // errCode returns the error.code of a JSON error body, or "" if body is not one (a // non-failing decodeErr for cases where the response may be a success). func errCode(body []byte) string { diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index 582abac..b86cd64 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -1416,8 +1416,17 @@ func (p *PGRepo) InsertOperator(ctx context.Context, id, username, email string) // CreateSession records a minted session by the sha-256 of its cookie value // (spec §B). Only the hash is stored, mirroring tokens. func (p *PGRepo) CreateSession(ctx context.Context, s NewSession) error { + return insertSession(ctx, p.db, s) +} + +// sqlExecer is the write half shared by *sql.DB and *sql.Tx. +type sqlExecer interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +func insertSession(ctx context.Context, db sqlExecer, s NewSession) error { reauth := sql.NullTime{Time: s.ReauthAt, Valid: !s.ReauthAt.IsZero()} - _, err := p.db.ExecContext(ctx, + _, err := db.ExecContext(ctx, `INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, client_ip, reauth_at) VALUES ($1, $2, $3, $4, $5, $6)`, s.TokenHash, s.UserID, s.ExpiresAt, s.UserAgent, s.ClientIP, reauth) @@ -2956,12 +2965,19 @@ func (p *PGRepo) ConsumeOpLoginRequest(ctx context.Context, id string, now time. // ---- setup token redemption (spec §B) ---- -// ConsumeSetupToken atomically marks a one-time setup token consumed and returns -// its user_id, or ErrNotFound when the token is absent, already consumed, or -// expired. The /setup?token=... web flow redeems it for a lockdown session. -func (p *PGRepo) ConsumeSetupToken(ctx context.Context, tokenHash string, now time.Time) (string, error) { +// RedeemSetupToken spends a one-time setup token and stores s as a session of the +// token's user in one transaction, so a failure leaves the token unspent. It +// returns the user id, or ErrNotFound when the token is absent, already spent, or +// expired. +func (p *PGRepo) RedeemSetupToken(ctx context.Context, tokenHash string, now time.Time, s NewSession) (string, error) { + tx, err := p.db.BeginTx(ctx, nil) + if err != nil { + return "", err + } + defer tx.Rollback() //nolint:errcheck // no-op after commit + var userID string - switch err := p.db.QueryRowContext(ctx, + switch err := tx.QueryRowContext(ctx, `UPDATE setup_tokens SET consumed_at = $2 WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > $2 RETURNING user_id`, @@ -2971,6 +2987,13 @@ func (p *PGRepo) ConsumeSetupToken(ctx context.Context, tokenHash string, now ti case err != nil: return "", err } + s.UserID = userID + if err := insertSession(ctx, tx, s); err != nil { + return "", err + } + if err := tx.Commit(); err != nil { + return "", err + } return userID, nil } @@ -2978,7 +3001,7 @@ func (p *PGRepo) ConsumeSetupToken(ctx context.Context, tokenHash string, now ti // hash (the raw value rides in the /setup?token=... URL). The setup Owner-bind // path uses CompleteOwnerSetup so identity binding, local auth, and this token // commit atomically; this lower-level helper remains for callers that already -// established the user. The token is redeemed exactly once by ConsumeSetupToken. +// established the user. The token is redeemed exactly once by RedeemSetupToken. func (p *PGRepo) CreateSetupToken(ctx context.Context, tokenHash, userID string, expiresAt time.Time) error { _, err := p.db.ExecContext(ctx, `INSERT INTO setup_tokens (token_hash, user_id, expires_at) VALUES ($1, $2, $3)`, diff --git a/internal/api/repo.go b/internal/api/repo.go index 279e58e..51e2234 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -722,11 +722,13 @@ type Repo interface { // it ended. RevokeOtherUserSessions(ctx context.Context, userID, keepTokenHash string) (int, error) - // ConsumeSetupToken atomically marks a one-time setup token consumed and returns - // its user_id, or ErrNotFound when the token is absent, already consumed, or - // expired. The /setup?token=... web flow redeems it for a lockdown session that - // can only complete passwordless login setup (verify email / enroll passkey). - ConsumeSetupToken(ctx context.Context, tokenHash string, now time.Time) (userID string, err error) + // RedeemSetupToken spends a one-time setup token and stores s as a session of + // the token's user (s.UserID is ignored) in one transaction, so a failure + // leaves the token unspent for another try. It returns the user id, or + // ErrNotFound when the token is absent, already spent, or expired. The /setup?token=... web flow redeems it for a lockdown + // session that can only complete passwordless login setup (verify email / + // enroll passkey). + RedeemSetupToken(ctx context.Context, tokenHash string, now time.Time, s NewSession) (userID string, err error) // ---- runtime platform settings (spec §B platform_settings) ---- diff --git a/internal/api/session.go b/internal/api/session.go index 0c093fd..35bf52e 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -80,12 +80,27 @@ const ( ) // startSession mints a session for userID and sets its cookie. Every sign-in door -// ends here, so every session records the device it was minted for. +// ends here (or at mintSession), so every session records the device it was +// minted for. func (a *API) startSession(w http.ResponseWriter, r *http.Request, userID string, proof signInProof) error { - token, err := newSessionToken() + token, s, err := a.mintSession(r, userID, proof) if err != nil { return err } + if err := a.Repo.CreateSession(r.Context(), s); err != nil { + return err + } + setSessionCookie(w, token, s.ExpiresAt) + return nil +} + +// mintSession makes a session cookie value and the row that stores it, for a door +// that writes the row itself. +func (a *API) mintSession(r *http.Request, userID string, proof signInProof) (string, NewSession, error) { + token, err := newSessionToken() + if err != nil { + return "", NewSession{}, err + } now := a.now() expires := now.Add(sessionTTL) ip := "" @@ -96,18 +111,14 @@ func (a *API) startSession(w http.ResponseWriter, r *http.Request, userID string if proof == provenSignIn { reauth = now } - if err := a.Repo.CreateSession(r.Context(), NewSession{ + return token, NewSession{ TokenHash: hashCookie(token), UserID: userID, ExpiresAt: expires, UserAgent: truncateUTF8(r.UserAgent(), maxSessionUserAgent), ClientIP: ip, ReauthAt: reauth, - }); err != nil { - return err - } - setSessionCookie(w, token, expires) - return nil + }, nil } // currentSessionHash is the storage key of the session cookie r carries, or "" diff --git a/internal/pgint/accounts_test.go b/internal/pgint/accounts_test.go index 6c151ca..00dfaa5 100644 --- a/internal/pgint/accounts_test.go +++ b/internal/pgint/accounts_test.go @@ -7,6 +7,7 @@ import ( "database/sql" "errors" "sort" + "strconv" "strings" "sync" "testing" @@ -595,8 +596,10 @@ func TestCrossingMigrationsDoNotDeadlock(t *testing.T) { // ---- setup tokens (migration 0012) -------------------------------------------------- // A setup token redeems once, never at or after expiry, and racing redeems of one -// token yield one session. -func TestConsumeSetupTokenContract(t *testing.T) { +// token yield one session. The session is written with the spend: the token's +// user owns it, and a redemption whose session cannot be stored leaves the token +// for the next try. +func TestRedeemSetupTokenContract(t *testing.T) { ctx := context.Background() u := newUser(t, "user", "setup") t0 := mustNow().Truncate(time.Second) @@ -606,27 +609,64 @@ func TestConsumeSetupTokenContract(t *testing.T) { t.Fatalf("CreateSetupToken: %v", err) } } + var mu sync.Mutex + minted := 0 + redeem := func(hash string, at time.Time) (string, string, error) { + mu.Lock() + minted++ + s := api.NewSession{TokenHash: "st-sess-" + strconv.Itoa(minted) + "-" + suffix(t), UserID: "ignored", ExpiresAt: t0.Add(time.Hour)} + mu.Unlock() + id, err := repo.RedeemSetupToken(ctx, hash, at, s) + return id, s.TokenHash, err + } + sessionOf := func(hash string) string { + t.Helper() + su, err := repo.SessionUser(ctx, hash, t0) + if errors.Is(err, api.ErrNotFound) { + return "" + } + if err != nil { + t.Fatalf("SessionUser: %v", err) + } + return su.ID + } + once := "st-once-" + suffix(t) create(once) - if got, err := repo.ConsumeSetupToken(ctx, once, t0); err != nil || got != u.ID { + got, sess, err := redeem(once, t0) + if err != nil || got != u.ID { t.Fatalf("redeem = %q, %v; want %s", got, err, u.ID) } - if _, err := repo.ConsumeSetupToken(ctx, once, t0); !errors.Is(err, api.ErrNotFound) { - t.Fatalf("replay = %v, want ErrNotFound", err) + if owner := sessionOf(sess); owner != u.ID { + t.Fatalf("redeemed session belongs to %q, want %s", owner, u.ID) } - if _, err := repo.ConsumeSetupToken(ctx, "st-never-"+suffix(t), t0); !errors.Is(err, api.ErrNotFound) { + if _, sess, err := redeem(once, t0); !errors.Is(err, api.ErrNotFound) || sessionOf(sess) != "" { + t.Fatalf("replay = %v (session stored: %v), want ErrNotFound and no session", err, sessionOf(sess) != "") + } + if _, _, err := redeem("st-never-"+suffix(t), t0); !errors.Is(err, api.ErrNotFound) { t.Fatalf("unknown token = %v, want ErrNotFound", err) } late := "st-late-" + suffix(t) create(late) - if _, err := repo.ConsumeSetupToken(ctx, late, t0.Add(10*time.Minute)); !errors.Is(err, api.ErrNotFound) { + if _, _, err := redeem(late, t0.Add(10*time.Minute)); !errors.Is(err, api.ErrNotFound) { t.Fatalf("redeem at expiry = %v, want ErrNotFound", err) } - if got, err := repo.ConsumeSetupToken(ctx, late, t0.Add(10*time.Minute-time.Second)); err != nil || got != u.ID { + if got, _, err := redeem(late, t0.Add(10*time.Minute-time.Second)); err != nil || got != u.ID { t.Fatalf("redeem a second before expiry = %q, %v; want %s (the refused try must not spend it)", got, err, u.ID) } + // The session's hash is taken, so storing it fails: the token stays unspent. + kept := "st-kept-" + suffix(t) + create(kept) + taken := newSession(t, u.ID, "st-taken", t0.Add(time.Hour)) + if _, err := repo.RedeemSetupToken(ctx, kept, t0, api.NewSession{TokenHash: taken, ExpiresAt: t0.Add(time.Hour)}); err == nil || errors.Is(err, api.ErrNotFound) { + t.Fatalf("redeem into a taken session hash = %v, want the insert failure", err) + } + if got, _, err := redeem(kept, t0); err != nil || got != u.ID { + t.Fatalf("retry after the failed session = %q, %v; want %s (the token must survive)", got, err, u.ID) + } + raced := "st-race-" + suffix(t) create(raced) var wg sync.WaitGroup @@ -635,7 +675,7 @@ func TestConsumeSetupTokenContract(t *testing.T) { wg.Add(1) go func(i int) { defer wg.Done() - _, errs[i] = repo.ConsumeSetupToken(ctx, raced, t0) + _, _, errs[i] = redeem(raced, t0) }(i) } wg.Wait() diff --git a/panel/src/i18n/resources/en-US/auth.json b/panel/src/i18n/resources/en-US/auth.json index eb38c8a..7708868 100644 --- a/panel/src/i18n/resources/en-US/auth.json +++ b/panel/src/i18n/resources/en-US/auth.json @@ -42,6 +42,9 @@ "setup_invalid_subtitle": "This setup link is invalid or has already been used", "setup_invalid_hint_prefix": "Re-run ", "setup_invalid_hint_suffix": " on the server to get a fresh setup link, or head to the sign-in page.", + "setup_failed_title": "Setup didn't start", + "setup_failed_subtitle": "The server couldn't sign you in just now. The setup link is kept, so you can try again.", + "setup_retry": "Try again", "setup_goto_login": "Go to sign in", "setup_email_step": "Step 1 · Add your email", "setup_email_desc": "We'll save this address for your account. You can configure email delivery (SMTP) and verify it later from Settings — it isn't required to finish setup.", diff --git a/panel/src/i18n/resources/zh-CN/auth.json b/panel/src/i18n/resources/zh-CN/auth.json index eb7f038..1e52811 100644 --- a/panel/src/i18n/resources/zh-CN/auth.json +++ b/panel/src/i18n/resources/zh-CN/auth.json @@ -42,6 +42,9 @@ "setup_invalid_subtitle": "这个设置链接无效或已被使用", "setup_invalid_hint_prefix": "请在服务器上重新运行 ", "setup_invalid_hint_suffix": " 获取新的设置链接,或直接前往登录页。", + "setup_failed_title": "设置没能开始", + "setup_failed_subtitle": "服务器暂时没能让你登录。设置链接仍然有效,可以再试一次。", + "setup_retry": "重试", "setup_goto_login": "前往登录", "setup_email_step": "第一步 · 填写邮箱", "setup_email_desc": "我们会为你的账户保存这个邮箱地址。发信服务(SMTP)和邮箱验证可稍后在设置中配置——完成初始化并不需要它。", diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index 4ccc039..ba02db3 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -964,7 +964,7 @@ export interface paths { put?: never; /** * Redeem a one-time setup token into a lockdown session (spec §B). - * @description Public, pre-session first-run door: consumes the one-time setup token minted by the felis TUI (stored and looked up by SHA-256 hash, like session cookies), mints a host-only felis_session, and returns the remaining setup steps so the SPA can drive the wizard. An unknown, consumed, or expired token returns a uniform 400 setup_token_invalid. Gated on local_auth_enabled. + * @description Public, pre-session first-run door: consumes the one-time setup token minted by the felis TUI (stored and looked up by SHA-256 hash, like session cookies), mints a host-only felis_session, and returns the remaining setup steps so the SPA can drive the wizard. An unknown, consumed, or expired token returns a uniform 400 setup_token_invalid. Gated on local_auth_enabled. The token is spent in the same transaction that stores the session, so a redemption that fails with 500 leaves the link working for another try. */ post: operations["setupRedeem"]; delete?: never; @@ -5345,6 +5345,7 @@ export interface operations { }; }; 429: components["responses"]["RateLimited"]; + 500: components["responses"]["InternalError"]; }; }; setupStatus: { diff --git a/panel/src/pages/Setup.test.tsx b/panel/src/pages/Setup.test.tsx new file mode 100644 index 0000000..0e85137 --- /dev/null +++ b/panel/src/pages/Setup.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import i18next from "i18next"; +import { Setup } from "./Setup"; + +const calls = vi.hoisted(() => ({ + setupRedeem: vi.fn(), + setupStatus: vi.fn(), + refresh: vi.fn(), +})); +vi.mock("@/lib/tier", () => ({ + useTier: () => ({ loading: false, identity: null, refresh: calls.refresh }), +})); +vi.mock("@/lib/api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + api: { ...actual.api, setupRedeem: calls.setupRedeem, setupStatus: calls.setupStatus }, + }; +}); + +const t = (key: string, opts?: Record) => i18next.t(key, opts); + +const fresh = { + user_id: "o1", + username: "owner", + role: "owner", + email: "", + email_verified: false, + has_passkey: false, + setup_required: true, +}; + +function renderSetup() { + return render( + + + , + ); +} + +beforeEach(() => { + for (const fn of Object.values(calls)) fn.mockReset(); + // No session survives a failed redeem. + calls.setupStatus.mockRejectedValue({ status: 401, code: "unauthenticated", message: "" }); +}); + +describe("Setup", () => { + it.each([ + [500, "internal"], + [503, "service_unavailable"], + [429, "rate_limited"], + [0, "network_error"], + ])("offers another try with the same link after a %i %s", async (status, code) => { + calls.setupRedeem.mockRejectedValueOnce({ status, code, message: "" }); + calls.setupRedeem.mockResolvedValueOnce(fresh); + renderSetup(); + + expect(await screen.findByText(t("auth:setup_failed_title"))).toBeTruthy(); + expect(screen.queryByText(t("auth:setup_invalid_subtitle"))).toBeNull(); + await userEvent.click(screen.getByRole("button", { name: t("auth:setup_retry") })); + + expect(await screen.findByText(t("auth:setup_welcome", { name: "owner" }))).toBeTruthy(); + expect(calls.setupRedeem.mock.calls).toEqual([["raw-token"], ["raw-token"]]); + }); + + it("sends a spent link back to felis setup, with nothing to retry", async () => { + calls.setupRedeem.mockRejectedValue({ status: 400, code: "setup_token_invalid", message: "" }); + renderSetup(); + + expect(await screen.findByText(t("auth:setup_invalid_subtitle"))).toBeTruthy(); + expect(await screen.findByText(t("errors:setup_token_invalid"))).toBeTruthy(); + expect(screen.queryByRole("button", { name: t("auth:setup_retry") })).toBeNull(); + }); +}); diff --git a/panel/src/pages/Setup.tsx b/panel/src/pages/Setup.tsx index 901920c..9ea89f1 100644 --- a/panel/src/pages/Setup.tsx +++ b/panel/src/pages/Setup.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { api, clientError, humanizeError, type SetupState } from "@/lib/api"; +import type { ApiError } from "@/lib/types"; import { base64urlToBytes, bytesToBase64url } from "@/lib/utils"; import { useTier } from "@/lib/tier"; import { InlineError } from "@/components/MessageLine"; @@ -27,6 +28,15 @@ import { InlineError } from "@/components/MessageLine"; // a spent token. The step endpoints and /me are all SetupAllowed, so the lockdown // session can complete the wizard; the backend lifts the lockdown once a passkey is // enrolled, and we hand off to / once nothing remains. +// retryable tells a failure worth another try with the same link (the server or +// the network failed, or asked to slow down) from a link that cannot work. The +// redeem spends the token only together with the session it mints, so a failed +// try leaves the link as it was. +function retryable(e: unknown): boolean { + const status = (e as Partial | undefined)?.status; + return status === 0 || status === 429 || (typeof status === "number" && status >= 500); +} + export function Setup() { const [params] = useSearchParams(); const navigate = useNavigate(); @@ -35,7 +45,8 @@ export function Setup() { const [state, setState] = useState(null); const [booting, setBooting] = useState(true); - const [fatal, setFatal] = useState(null); + const [fatal, setFatal] = useState<{ message: string; retryable: boolean } | null>(null); + const [attempt, setAttempt] = useState(0); const finishing = useRef(false); // Boot: redeem the URL token, or resume from the session if the token is already @@ -63,7 +74,7 @@ export function Setup() { } if (alive) setState(st); } catch (e) { - if (alive) setFatal(humanizeError(e)); + if (alive) setFatal({ message: humanizeError(e), retryable: retryable(e) }); } finally { if (alive) setBooting(false); } @@ -71,7 +82,13 @@ export function Setup() { return () => { alive = false; }; - }, [params]); + }, [params, attempt]); + + const retry = () => { + setFatal(null); + setBooting(true); + setAttempt((n) => n + 1); + }; // reload re-reads progress after a wizard step so the view advances to the next. const reload = useCallback(async () => { @@ -103,12 +120,27 @@ export function Setup() { ); } + if (fatal?.retryable) { + return ( + + + +

{fatal.message}

+ +
+
+
+ ); + } + if (fatal) { return ( -

{fatal}

+

{fatal.message}

{t("setup_invalid_hint_prefix")}