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[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: "test@lab.local", 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("test@lab.local"); 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); }); });