Files
emdash-patch-imageupload/packages/core/tests/integration/astro/setup-admin-nonce-success.test.ts
kunthawat 2d1be52177 Emdash source with visual editor image upload fix
Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
2026-05-03 10:44:54 +07:00

189 lines
6.1 KiB
TypeScript

/**
* Success-path coverage for the setup nonce cookie.
*
* The sibling file `setup-admin-nonce.test.ts` covers the negative
* paths (missing cookie, mismatched cookie, rotation) by driving
* /setup/admin/verify with a bogus credential that fails at the
* WebAuthn step. That harness can't exercise the *successful* verify
* path — real WebAuthn verification requires a live authenticator.
*
* This file stubs `verifyRegistrationResponse` with a fake that
* returns synthetic credential material so we can reach the code
* after the nonce gate: user creation, passkey registration, setup
* completion, and — the property we actually care about — deletion
* of the nonce cookie.
*
* `registerPasskey` is left real; it only talks to the Kysely
* adapter against the in-memory test DB.
*/
import type { APIContext, AstroCookies } from "astro";
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@emdash-cms/auth/passkey", async (importOriginal) => {
const actual = await importOriginal<typeof import("@emdash-cms/auth/passkey")>();
return {
...actual,
verifyRegistrationResponse: vi.fn(async () => ({
credentialId: "fake-credential-id",
publicKey: new Uint8Array([1, 2, 3, 4]),
counter: 0,
deviceType: "singleDevice" as const,
backedUp: false,
transports: [],
})),
};
});
// Deferred so vi.mock applies before the route modules evaluate.
type AdminRoute = typeof import("../../../src/astro/routes/api/setup/admin.js");
type AdminVerifyRoute = typeof import("../../../src/astro/routes/api/setup/admin-verify.js");
let postAdmin: AdminRoute["POST"];
let postAdminVerify: AdminVerifyRoute["POST"];
import { OptionsRepository } from "../../../src/database/repositories/options.js";
import type { Database } from "../../../src/database/types.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
interface CookieRecord {
value: string;
options: Record<string, unknown>;
deleted?: boolean;
}
interface CookieJar {
jar: Map<string, CookieRecord>;
cookies: AstroCookies;
}
function createCookieJar(initial: Record<string, string> = {}): CookieJar {
const jar = new Map<string, CookieRecord>();
for (const [name, value] of Object.entries(initial)) {
jar.set(name, { value, options: {} });
}
const cookies = {
get(name: string) {
const record = jar.get(name);
if (!record || record.deleted) return undefined;
return { value: record.value };
},
set(name: string, value: string, options: Record<string, unknown> = {}) {
jar.set(name, { value, options });
},
delete(name: string, options: Record<string, unknown> = {}) {
const existing = jar.get(name);
jar.set(name, {
value: existing?.value ?? "",
options: { ...existing?.options, ...options },
deleted: true,
});
},
has(name: string) {
const record = jar.get(name);
return !!record && !record.deleted;
},
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub
} as unknown as AstroCookies;
return { jar, cookies };
}
function buildAdminRequest(body: unknown): Request {
return new Request("http://localhost/_emdash/api/setup/admin", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function buildVerifyRequest(body: unknown): Request {
return new Request("http://localhost/_emdash/api/setup/admin/verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function buildContext(db: Kysely<Database>, request: Request, cookies: AstroCookies): APIContext {
return {
params: {},
url: new URL(request.url),
request,
cookies,
locals: {
emdash: {
db,
config: {},
storage: undefined,
},
},
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub
} as unknown as APIContext;
}
const adminBody = { email: "real@admin.example", name: "Real Admin" };
// Any object that passes setupAdminVerifyBody — the actual WebAuthn
// verification is mocked out, so the fields don't need to parse as
// valid authenticator data.
const fakeCredential = {
credential: {
id: "fake-credential-id",
rawId: "fake-credential-id",
type: "public-key" as const,
response: {
clientDataJSON: "AA",
attestationObject: "AA",
},
},
};
describe("POST /setup/admin/verify — success path clears nonce cookie", () => {
let db: Kysely<Database>;
beforeEach(async () => {
db = await setupTestDatabase();
({ POST: postAdmin } = await import("../../../src/astro/routes/api/setup/admin.js"));
({ POST: postAdminVerify } =
await import("../../../src/astro/routes/api/setup/admin-verify.js"));
});
afterEach(async () => {
await teardownTestDatabase(db);
});
it("deletes the setup nonce cookie and marks setup complete when verify succeeds", async () => {
// 1. Start admin setup — mints the nonce and drops the cookie.
const { jar, cookies } = createCookieJar();
const adminRes = await postAdmin(buildContext(db, buildAdminRequest(adminBody), cookies));
expect(adminRes.status).toBe(200);
const setCookie = jar.get("emdash_setup_nonce");
expect(setCookie).toBeDefined();
expect(setCookie!.deleted).toBeFalsy();
// 2. Verify with the mocked-out WebAuthn check. The nonce gate
// runs first (real code path), then the stub returns a
// synthetic credential and the route creates the user.
const verifyRes = await postAdminVerify(
buildContext(db, buildVerifyRequest(fakeCredential), cookies),
);
expect(verifyRes.status).toBe(200);
// 3. Cookie should now be deleted. The deletion must be
// scoped to /_emdash/ so it actually supersedes the cookie
// the browser holds.
const afterVerify = jar.get("emdash_setup_nonce");
expect(afterVerify?.deleted).toBe(true);
expect(afterVerify?.options.path).toBe("/_emdash/");
// 4. Setup state is cleared and setup_complete is set.
const options = new OptionsRepository(db);
const setupState = await options.get("emdash:setup_state");
expect(setupState).toBeNull();
const setupComplete = await options.get("emdash:setup_complete");
expect(setupComplete).toBe(true);
});
});