b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
93 lines
3.4 KiB
TypeScript
93 lines
3.4 KiB
TypeScript
/**
|
|
* Backup route tests
|
|
*
|
|
* - Route registration for all four backup endpoints
|
|
* - Authorization: every endpoint requires the admin-only backups:manage
|
|
* permission
|
|
* - The public media file route must never serve keys under backups/
|
|
* (archives contain the site's full content export)
|
|
*/
|
|
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { injectCoreRoutes } from "../../../src/astro/integration/routes.js";
|
|
import { GET as mediaFileGet } from "../../../src/astro/routes/api/media/file/[...key].js";
|
|
import {
|
|
DELETE as archiveDelete,
|
|
GET as archiveGet,
|
|
} from "../../../src/astro/routes/api/settings/backups/archives/[name].js";
|
|
import { POST as archivesPost } from "../../../src/astro/routes/api/settings/backups/archives/index.js";
|
|
import { GET as exportGet } from "../../../src/astro/routes/api/settings/backups/export.js";
|
|
import {
|
|
GET as backupsGet,
|
|
PUT as backupsPut,
|
|
} from "../../../src/astro/routes/api/settings/backups/index.js";
|
|
|
|
// Minimal APIContext stand-in; routes only touch locals/params/request.
|
|
// eslint-disable-next-line typescript/no-explicit-any -- test double for APIContext
|
|
function ctx(overrides: Record<string, unknown>): any {
|
|
return {
|
|
locals: { emdash: { db: {}, storage: {} }, user: null },
|
|
params: {},
|
|
request: new Request("https://example.com"),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("backup route registration", () => {
|
|
it("registers all backup routes", () => {
|
|
const injectRoute = vi.fn();
|
|
injectCoreRoutes(injectRoute);
|
|
|
|
const patterns = injectRoute.mock.calls.map((call) => (call[0] as { pattern: string }).pattern);
|
|
expect(patterns).toContain("/_emdash/api/settings/backups");
|
|
expect(patterns).toContain("/_emdash/api/settings/backups/export");
|
|
expect(patterns).toContain("/_emdash/api/settings/backups/archives");
|
|
expect(patterns).toContain("/_emdash/api/settings/backups/archives/[name]");
|
|
});
|
|
});
|
|
|
|
describe("backup route authorization", () => {
|
|
const cases: [string, (c: unknown) => Promise<Response>][] = [
|
|
["GET /settings/backups", (c) => backupsGet(c as never)],
|
|
["PUT /settings/backups", (c) => backupsPut(c as never)],
|
|
["GET /settings/backups/export", (c) => exportGet(c as never)],
|
|
["POST /settings/backups/archives", (c) => archivesPost(c as never)],
|
|
["GET /settings/backups/archives/[name]", (c) => archiveGet(c as never)],
|
|
["DELETE /settings/backups/archives/[name]", (c) => archiveDelete(c as never)],
|
|
];
|
|
|
|
for (const [label, invoke] of cases) {
|
|
it(`${label} rejects anonymous requests`, async () => {
|
|
const res = await invoke(
|
|
ctx({ params: { name: "emdash-backup-2026-07-09T08-45-12-abcd1234.json" } }),
|
|
);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it(`${label} rejects editors (below admin)`, async () => {
|
|
const res = await invoke(
|
|
ctx({
|
|
locals: { emdash: { db: {}, storage: {} }, user: { id: "u1", role: 40 } },
|
|
params: { name: "emdash-backup-2026-07-09T08-45-12-abcd1234.json" },
|
|
}),
|
|
);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("media file route denies backup archives", () => {
|
|
it("returns 404 for keys under backups/ without touching storage", async () => {
|
|
const download = vi.fn();
|
|
const res = await mediaFileGet(
|
|
ctx({
|
|
params: { key: "backups/emdash-backup-2026-07-09T08-45-12-abcd1234.json" },
|
|
locals: { emdash: { storage: { download } } },
|
|
}) as never,
|
|
);
|
|
expect(res.status).toBe(404);
|
|
expect(download).not.toHaveBeenCalled();
|
|
});
|
|
});
|