// @vitest-environment jsdom // // Settings → API tokens: a row per token says what it is, what it opens with which rights, when it was // last used and when it runs out (marked when that is soon); revoking asks first. A new token is the // Hub exchange in one click, or folders and rights picked one by one — never Write where an upload // cannot land — and its value is shown once, with a curl line to try it. i18n is key-passthrough // (a key's options ride along in brackets, so a test can read what was named). import { afterEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { api, type ApiToken, type FileFolder } from "../../lib/api"; const mocks = vi.hoisted(() => ({ confirm: vi.fn(async (_o?: unknown) => true) })); vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), useTranslation: () => ({ i18n: { language: "en" }, t: (key: string, o?: Record) => o && Object.keys(o).length ? `${key}(${Object.values(o).join(",")})` : key, }), })); vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => mocks.confirm })); vi.mock("../../lib/clipboard", () => ({ copyText: vi.fn(async () => true) })); import ApiTokens from "./ApiTokens"; afterEach(() => { cleanup(); vi.restoreAllMocks(); mocks.confirm.mockClear(); }); const token = (id: number, extra: Partial = {}): ApiToken => ({ id, name: `Token ${id}`, prefix: `sfl_${id}abcdefg`, created_at: "2026-10-01T00:00:00Z", expires_at: "2027-10-01T00:00:00Z", expired: false, expiring: false, last_used_at: null, grants: [], ...extra, }); const folder = (id: number, extra: Partial): FileFolder => ({ id, kind: "source", source_key: null, name: null, retention_days: 30, count: 1, size_bytes: 0, created_at: null, has_link: false, ...extra, }); function mount(tokens: ApiToken[] | Error, folders: FileFolder[] = []) { if (tokens instanceof Error) vi.spyOn(api, "apiTokens").mockRejectedValue(tokens); else vi.spyOn(api, "apiTokens").mockResolvedValue(tokens); vi.spyOn(api, "fileFolders").mockResolvedValue(folders); render( , ); } const rows = () => screen.getAllByTestId("api-token-row"); const dialog = () => screen.getByRole("dialog"); const pressed = (b: HTMLElement) => b.getAttribute("aria-pressed") === "true"; const inRow = (group: string, name: string) => [...screen.getByRole("group", { name: group }).querySelectorAll("button")].find( (b) => b.textContent === name, ); describe("API tokens · the list", () => { it("reads what a token opens, with which rights, when it was used and when it runs out", async () => { mount([ token(1, { name: "Hub exchange", last_used_at: new Date().toISOString(), grants: [ { folder_id: 7, kind: "hub", source_key: null, name: null, rights: ["write", "list", "read", "delete"], }, ], }), token(2, { expiring: true, expires_at: null, grants: [ { folder_id: 3, kind: "own", source_key: null, name: "Receptek", rights: ["read"] }, ], }), token(3, { expired: true }), ]); await waitFor(() => expect(rows()).toHaveLength(3)); const [hub, soon, gone] = rows(); expect(hub!.textContent).toContain("sfl_1abcdefg…"); // Rights in the server's order, whatever order they came in. expect(hub!.textContent).toContain( "files.folder.hub· settings.tokens.rights.list, settings.tokens.rights.read, settings.tokens.rights.write, settings.tokens.rights.delete", ); expect(hub!.textContent).toContain("settings.tokens.lastUsed"); expect(hub!.textContent).not.toContain("settings.tokens.expiring"); expect(soon!.textContent).toContain("Receptek· settings.tokens.rights.read"); expect(soon!.textContent).toContain("settings.tokens.expiring"); expect(soon!.textContent).toContain("settings.tokens.neverUsed"); expect(soon!.textContent).toContain("settings.tokens.neverExpires"); expect(gone!.textContent).toContain("settings.tokens.expired"); expect(gone!.textContent).toContain("settings.tokens.expiredOn("); }); it("says when there is none, and when the list could not be read", async () => { mount([]); expect(await screen.findByText("settings.tokens.empty")).toBeTruthy(); cleanup(); mount(new Error("down")); expect(await screen.findByText("common.loadError")).toBeTruthy(); expect(screen.queryByText("settings.tokens.empty")).toBeNull(); }); it("revokes from the row's menu, only once the confirm says yes", async () => { mount([token(5, { name: "Phone" })]); const revoke = vi.spyOn(api, "revokeApiToken").mockResolvedValue({ ok: true, deleted: 5 }); await waitFor(() => expect(rows()).toHaveLength(1)); mocks.confirm.mockResolvedValueOnce(false); const open = async () => { act(() => { fireEvent.contextMenu(rows()[0]!); }); await waitFor(() => expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0), ); fireEvent.click( screen .getAllByRole("menuitem", { hidden: true }) .find((b) => b.textContent === "settings.tokens.revoke")!, ); }; await open(); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); expect(mocks.confirm.mock.calls[0]![0]).toMatchObject({ title: "settings.tokens.revokeTitle(Phone)", danger: true, }); expect(revoke).not.toHaveBeenCalled(); await open(); await waitFor(() => expect(revoke).toHaveBeenCalledWith(5)); }); }); describe("API tokens · making one", () => { const made = (extra: Partial = {}) => ({ ok: true as const, ...token(9, extra), token: "sfl_SECRETVALUE", }); it("makes the Hub exchange in one click, a year long, and shows the token once with a curl line", async () => { mount([]); const create = vi.spyOn(api, "createApiToken").mockResolvedValue(made()); fireEvent.click(await screen.findByText("settings.tokens.new")); expect(pressed(screen.getByRole("button", { name: "settings.tokens.hubTitle" }))).toBe(true); expect((screen.getByLabelText("settings.tokens.name") as HTMLInputElement).value).toBe( "settings.tokens.hubName", ); fireEvent.click(screen.getByRole("button", { name: "settings.tokens.create" })); await waitFor(() => expect(create).toHaveBeenCalledWith({ name: "settings.tokens.hubName", expires_days: 365, preset: "hub", }), ); await screen.findByText("settings.tokens.madeTitle"); const codes = [...dialog().querySelectorAll("code")].map((c) => c.textContent); expect(codes[0]).toBe("sfl_SECRETVALUE"); expect(codes[1]).toBe( `curl -H "Authorization: Bearer sfl_SECRETVALUE" ${window.location.origin}/api/files/folders`, ); // The keyboard lands on copying it, not on the closed form's button. expect(document.activeElement?.textContent).toBe("settings.tokens.copy"); // Two Copy buttons in one dialog: each is named by the value it copies. expect( screen.getByRole("button", { name: "settings.tokens.copyField(settings.tokens.token)" }), ).toBe(document.activeElement); screen.getByRole("button", { name: "settings.tokens.copyField(settings.tokens.tryIt)" }); // Once copied, its name says what it shows, so a voice command saying "Copied" finds it. fireEvent.click(document.activeElement!); await screen.findByRole("button", { name: "settings.tokens.copiedField(settings.tokens.token)", }); fireEvent.click(screen.getByRole("button", { name: "settings.tokens.done" })); expect(screen.queryByRole("dialog")).toBeNull(); }); it("a custom token takes the folders and rights picked; no Write where nothing can be uploaded", async () => { mount( [], [ folder(1, { source_key: "youtube" }), folder(2, { source_key: "upload" }), folder(3, { source_key: "site:x.com", count: 0 }), // empty: not offered, as the rail hides it folder(4, { kind: "hub", count: 0 }), folder(5, { kind: "own", name: "Receptek" }), ], ); const create = vi.spyOn(api, "createApiToken").mockResolvedValue(made()); fireEvent.click(await screen.findByText("settings.tokens.new")); fireEvent.click(screen.getByRole("button", { name: "settings.tokens.customTitle" })); const name = screen.getByLabelText("settings.tokens.name") as HTMLInputElement; expect(name.value).toBe(""); await screen.findByRole("group", { name: "Receptek" }); expect( screen .getAllByRole("group") .map((g) => g.getAttribute("aria-label")) .filter((l) => l !== "settings.tokens.kind" && l !== "settings.tokens.expiry"), ).toEqual(["files.source.upload", "files.source.youtube", "files.folder.hub", "Receptek"]); expect(inRow("files.source.youtube", "settings.tokens.rights.write")).toBeUndefined(); expect(inRow("files.source.upload", "settings.tokens.rights.write")).toBeTruthy(); expect(inRow("files.folder.hub", "settings.tokens.rights.write")).toBeTruthy(); const createBtn = screen.getByRole("button", { name: "settings.tokens.create" }); fireEvent.change(name, { target: { value: " Phone " } }); expect(createBtn.hasAttribute("disabled")).toBe(true); // a name, but no right yet fireEvent.click(inRow("Receptek", "settings.tokens.rights.list")!); fireEvent.click(inRow("Receptek", "settings.tokens.rights.write")!); fireEvent.click(inRow("files.source.youtube", "settings.tokens.rights.read")!); // Picked and dropped again: no grant for that folder. fireEvent.click(inRow("files.folder.hub", "settings.tokens.rights.read")!); fireEvent.click(inRow("files.folder.hub", "settings.tokens.rights.read")!); fireEvent.click(screen.getByRole("button", { name: "settings.tokens.expiryOption.30" })); expect(createBtn.hasAttribute("disabled")).toBe(false); fireEvent.click(createBtn); await waitFor(() => expect(create).toHaveBeenCalledTimes(1)); const body = create.mock.calls[0]![0]; expect(body).toMatchObject({ name: "Phone", expires_days: 30 }); expect(body.preset).toBeUndefined(); expect(body.grants).toEqual( expect.arrayContaining([ { folder_id: 5, rights: ["list", "write"] }, { folder_id: 1, rights: ["read"] }, ]), ); expect(body.grants).toHaveLength(2); }); it("a token that never runs out says what that means, and is sent without an expiry", async () => { mount([]); const create = vi.spyOn(api, "createApiToken").mockResolvedValue(made()); fireEvent.click(await screen.findByText("settings.tokens.new")); expect(screen.queryByText("settings.tokens.neverWarning")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "settings.tokens.expiryOption.never" })); expect(screen.getByText("settings.tokens.neverWarning")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "settings.tokens.create" })); await waitFor(() => expect(create).toHaveBeenCalledWith(expect.objectContaining({ expires_days: null })), ); }); });