fix(api): setup 链接在同一事务里消费并建会话,存储故障回 500 且链接仍可重试
This commit is contained in:
13 files changed
+312
-47
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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)和邮箱验证可稍后在设置中配置——完成初始化并不需要它。",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<typeof import("@/lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: { ...actual.api, setupRedeem: calls.setupRedeem, setupStatus: calls.setupStatus },
|
||||
};
|
||||
});
|
||||
|
||||
const t = (key: string, opts?: Record<string, unknown>) => 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(
|
||||
<MemoryRouter initialEntries={["/setup?token=raw-token"]}>
|
||||
<Setup />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<ApiError> | 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<SetupState | null>(null);
|
||||
const [booting, setBooting] = useState(true);
|
||||
const [fatal, setFatal] = useState<string | null>(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 (
|
||||
<AuthLayout title={t("setup_failed_title")} subtitle={t("setup_failed_subtitle")}>
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-6 text-sm">
|
||||
<p role="alert" className="text-muted-foreground">{fatal.message}</p>
|
||||
<Button className="w-full" onClick={retry}>
|
||||
{t("setup_retry")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (fatal) {
|
||||
return (
|
||||
<AuthLayout title={t("setup_invalid_title")} subtitle={t("setup_invalid_subtitle")}>
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-6 text-sm">
|
||||
<p role="alert" className="text-muted-foreground">{fatal}</p>
|
||||
<p role="alert" className="text-muted-foreground">{fatal.message}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t("setup_invalid_hint_prefix")}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
|
||||
|
||||
Reference in new issue
Block a user