Files
siftlode/frontend/src/modules/settings/ApiTokens.dom.test.tsx
T
peter 63a0fecc8b refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
2026-10-22 23:12:45 +02:00

270 lines
11 KiB
TypeScript

// @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<typeof import("react-i18next")>()),
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: Record<string, unknown>) =>
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> = {}): 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>): 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(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ApiTokens />
</QueryClientProvider>,
);
}
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<ApiToken> = {}) => ({
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 })),
);
});
});