Files
rfid-database/tests/integration/api.test.ts
Cursor Agent 9f4ec0a9c3 Strengthen CI with Next build + Docker build and expand tests
Add docker-build and next build jobs so Dockerfile/image failures fail
CI. Expand unit coverage for exporters/Zod and integration coverage for
search, MCT import, backup, and token revoke.
2026-08-23 23:34:04 +00:00

274 lines
8.6 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { hash } from "bcryptjs";
import { eq } from "drizzle-orm";
vi.mock("@/lib/auth/auth", () => ({
auth: async () => null,
isOidcConfigured: () => false,
handlers: {},
signIn: async () => undefined,
signOut: async () => undefined,
}));
import { getDb, resetDbConnection } from "@/db/client";
import { apiTokens, sites, users } from "@/db/schema";
import { generateApiToken } from "@/lib/auth/tokens";
import { GET as getSites, POST as postSite } from "@/app/api/v1/sites/route";
import { GET as getTags, POST as postTag } from "@/app/api/v1/tags/route";
import { PUT as putByUid } from "@/app/api/v1/tags/by-uid/[uid]/route";
import { GET as getExport } from "@/app/api/v1/tags/[id]/export/route";
import { GET as listTokens, POST as createToken } from "@/app/api/v1/tokens/route";
import { DELETE as deleteToken } from "@/app/api/v1/tokens/[id]/route";
import { GET as search } from "@/app/api/v1/search/route";
import { GET as getBackup, POST as postBackup } from "@/app/api/v1/backup/route";
import { POST as importTag } from "@/app/api/v1/tags/import/route";
import { NextRequest } from "next/server";
function req(url: string, init?: ConstructorParameters<typeof NextRequest>[1]) {
return new NextRequest(new URL(url, "http://localhost"), init);
}
describe("API v1 integration", () => {
let dbPath: string;
let bearer: string;
let userId: string;
beforeEach(async () => {
dbPath = path.join(os.tmpdir(), `rfid-test-${crypto.randomUUID()}.db`);
process.env.RFID_DB_PATH = dbPath;
process.env.AUTH_SECRET = "test-secret-at-least-32-characters-long";
resetDbConnection();
const db = getDb();
userId = crypto.randomUUID();
const passwordHash = await hash("password", 4);
db.insert(users)
.values({
id: userId,
email: "[email protected]",
name: "Tester",
passwordHash,
createdAt: new Date(),
})
.run();
const generated = generateApiToken();
bearer = generated.token;
db.insert(apiTokens)
.values({
id: crypto.randomUUID(),
userId,
name: "test",
tokenHash: generated.tokenHash,
prefix: generated.prefix,
expiresAt: null,
lastUsedAt: null,
createdAt: new Date(),
})
.run();
});
afterEach(() => {
resetDbConnection();
if (dbPath && fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
});
it("rejects unauthenticated requests", async () => {
const res = await getSites(req("http://localhost/api/v1/sites"));
expect(res.status).toBe(401);
});
it("creates site, tag, upserts by uid, exports", async () => {
const auth = { Authorization: `Bearer ${bearer}` };
const siteRes = await postSite(
req("http://localhost/api/v1/sites", {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ code: "A", name: "Building 1" }),
})
);
expect(siteRes.status).toBe(201);
const site = await siteRes.json();
const listRes = await getSites(
req("http://localhost/api/v1/sites", { headers: auth })
);
expect(listRes.status).toBe(200);
const list = await listRes.json();
expect(list.sites).toHaveLength(1);
const tagRes = await postTag(
req("http://localhost/api/v1/tags", {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({
siteId: site.id,
label: "Dock fob",
frequency: "HF",
protocol: "MIFARE_CLASSIC_1K",
uid: "04:A1:B2:C3",
dumpData: {
size: "1K",
sectors: [
{
index: 0,
blocks: [
"04A1B2C304A1B2C304A1B2C304A1B2C3",
"00000000000000000000000000000000",
"00000000000000000000000000000000",
"FFFFFFFFFFFFFF078069FFFFFFFFFFFF",
],
},
],
},
keys: { A: ["FFFFFFFFFFFF"], B: [] },
}),
})
);
expect(tagRes.status).toBe(201);
const tag = await tagRes.json();
expect(tag.uid).toBe("04A1B2C3");
const upsert = await putByUid(
req(`http://localhost/api/v1/tags/by-uid/04A1B2C3?siteId=${site.id}`, {
method: "PUT",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({
siteId: site.id,
label: "Dock fob updated",
frequency: "HF",
protocol: "MIFARE_CLASSIC_1K",
uid: "04A1B2C3",
dumpData: tag.dumpData,
keys: tag.keys,
}),
}),
{ params: Promise.resolve({ uid: "04A1B2C3" }) }
);
expect(upsert.status).toBe(200);
const upserted = await upsert.json();
expect(upserted.created).toBe(false);
expect(upserted.label).toBe("Dock fob updated");
const tagsRes = await getTags(
req(`http://localhost/api/v1/tags?siteId=${site.id}`, { headers: auth })
);
const tags = await tagsRes.json();
expect(tags.tags).toHaveLength(1);
const exportRes = await getExport(
req(`http://localhost/api/v1/tags/${tag.id}/export?format=mct`, {
headers: auth,
}),
{ params: Promise.resolve({ id: tag.id }) }
);
expect(exportRes.status).toBe(200);
const body = await exportRes.text();
expect(body).toContain("+UID:");
expect(body).toContain("+Sector: 0");
});
it("creates and lists API tokens", async () => {
const auth = { Authorization: `Bearer ${bearer}` };
const created = await createToken(
req("http://localhost/api/v1/tokens", {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ name: "cli" }),
})
);
expect(created.status).toBe(201);
const data = await created.json();
expect(data.token).toMatch(/^rfid_/);
const listed = await listTokens(
req("http://localhost/api/v1/tokens", { headers: auth })
);
const list = await listed.json();
expect(list.tokens.some((t: { name: string }) => t.name === "cli")).toBe(
true
);
const revoked = await deleteToken(
req(`http://localhost/api/v1/tokens/${data.id}`, {
method: "DELETE",
headers: auth,
}),
{ params: Promise.resolve({ id: data.id }) }
);
expect(revoked.status).toBe(200);
const db = getDb();
const user = db.select().from(users).where(eq(users.id, userId)).get();
expect(user?.email).toBe("[email protected]");
expect(db.select().from(sites).all().length).toBeGreaterThanOrEqual(0);
});
it("searches, imports MCT, and backs up", async () => {
const auth = { Authorization: `Bearer ${bearer}` };
const siteRes = await postSite(
req("http://localhost/api/v1/sites", {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ code: "C", name: "Lab" }),
})
);
const site = await siteRes.json();
const mct = `+UID: AABBCCDD
+Sector: 0
AABBCCDD00112233445566778899AABB
00000000000000000000000000000000
00000000000000000000000000000000
FFFFFFFFFFFFFF078069FFFFFFFFFFFF
`;
const imported = await importTag(
req("http://localhost/api/v1/tags/import", {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({
siteId: site.id,
label: "Imported fob",
content: mct,
filename: "sample.mct",
}),
})
);
expect(imported.status).toBe(201);
const tag = await imported.json();
expect(tag.uid).toBe("AABBCCDD");
const searchRes = await search(
req("http://localhost/api/v1/search?q=Imported", { headers: auth })
);
expect(searchRes.status).toBe(200);
const found = await searchRes.json();
expect(found.tags.some((t: { label: string }) => t.label === "Imported fob")).toBe(
true
);
const backupRes = await getBackup(
req("http://localhost/api/v1/backup", { headers: auth })
);
expect(backupRes.status).toBe(200);
const backup = await backupRes.json();
expect(backup.sites.length).toBeGreaterThanOrEqual(1);
expect(backup.tags.length).toBeGreaterThanOrEqual(1);
const restore = await postBackup(
req("http://localhost/api/v1/backup", {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ ...backup, mode: "merge" }),
})
);
expect(restore.status).toBe(200);
});
});