mirror of
https://github.com/Chewbaccalakis/rfid-database.git
synced 2026-09-09 16:01:56 -07:00
Implement RFID tag dump PWA with REST API, auth, and CI (#1)
Add Next.js app with SQLite/Drizzle storage for sites and LF/HF tag dumps, multi-user Auth.js (credentials + optional OIDC), personal API tokens in Settings, versioned /api/v1 for UI and future CLI use, MCT/Proxmark/JSON import-export, PWA offline shell, Vitest tests, and GitHub Actions CI. Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
co-authored by
Cursor Agent
parent
5029945cc2
commit
8500d2f673
@@ -0,0 +1,197 @@
|
||||
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 { 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 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseMct, parseImport } from "@/lib/rfid/parsers";
|
||||
import { exportTag } from "@/lib/rfid/exporters";
|
||||
import { mifareClassicDumpSchema } from "@/lib/validation/rfid";
|
||||
|
||||
const sampleMct = `+UID: 04A1B2C3
|
||||
+Sector: 0
|
||||
04A1B2C304A1B2C304A1B2C304A1B2C3
|
||||
00000000000000000000000000000000
|
||||
00000000000000000000000000000000
|
||||
FFFFFFFFFFFFFF078069FFFFFFFFFFFF
|
||||
+Sector: 1
|
||||
00000000000000000000000000000000
|
||||
00000000000000000000000000000000
|
||||
00000000000000000000000000000000
|
||||
FFFFFFFFFFFFFF078069FFFFFFFFFFFF
|
||||
`;
|
||||
|
||||
describe("MCT parser/exporter", () => {
|
||||
it("parses MCT dumps", () => {
|
||||
const tag = parseMct(sampleMct);
|
||||
expect(tag.uid).toBe("04A1B2C3");
|
||||
expect(tag.protocol).toBe("MIFARE_CLASSIC_1K");
|
||||
expect(mifareClassicDumpSchema.safeParse(tag.dumpData).success).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips via export mct", () => {
|
||||
const tag = parseMct(sampleMct);
|
||||
const exported = exportTag(
|
||||
{
|
||||
id: "00000000-0000-4000-8000-000000000001",
|
||||
label: "test",
|
||||
frequency: tag.frequency,
|
||||
protocol: tag.protocol,
|
||||
uid: tag.uid,
|
||||
dumpData: tag.dumpData,
|
||||
keys: null,
|
||||
notes: null,
|
||||
},
|
||||
"mct"
|
||||
);
|
||||
const again = parseImport(exported.body, "test.mct");
|
||||
expect(again.uid).toBe(tag.uid);
|
||||
expect(again.protocol).toBe(tag.protocol);
|
||||
});
|
||||
|
||||
it("parses canonical JSON", () => {
|
||||
const json = JSON.stringify({
|
||||
label: "Dock",
|
||||
frequency: "HF",
|
||||
protocol: "MIFARE_CLASSIC_1K",
|
||||
uid: "04:A1:B2:C3",
|
||||
dumpData: {
|
||||
size: "1K",
|
||||
sectors: [{ index: 0, blocks: ["AABBCCDDEEFF00112233445566778899"] }],
|
||||
},
|
||||
});
|
||||
const tag = parseImport(json, "x.json");
|
||||
expect(tag.uid).toBe("04A1B2C3");
|
||||
expect(tag.label).toBe("Dock");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateApiToken, hashToken, isApiTokenFormat } from "@/lib/auth/tokens";
|
||||
|
||||
describe("api tokens", () => {
|
||||
it("generates rfid_ prefixed tokens", () => {
|
||||
const { token, tokenHash, prefix } = generateApiToken();
|
||||
expect(isApiTokenFormat(token)).toBe(true);
|
||||
expect(tokenHash).toBe(hashToken(token));
|
||||
expect(prefix).toBe(token.slice(0, 12));
|
||||
expect(hashToken(token)).not.toBe(token);
|
||||
});
|
||||
|
||||
it("rejects non-tokens", () => {
|
||||
expect(isApiTokenFormat("Bearer abc")).toBe(false);
|
||||
expect(isApiTokenFormat("rfid_short")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatUid, normalizeUid, tryNormalizeUid } from "@/lib/rfid/uid";
|
||||
|
||||
describe("normalizeUid", () => {
|
||||
it("strips separators and uppercases", () => {
|
||||
expect(normalizeUid("04:a1:b2:c3")).toBe("04A1B2C3");
|
||||
expect(normalizeUid("04 a1 b2 c3")).toBe("04A1B2C3");
|
||||
expect(normalizeUid("04a1b2c3")).toBe("04A1B2C3");
|
||||
});
|
||||
|
||||
it("rejects odd length", () => {
|
||||
expect(() => normalizeUid("04A")).toThrow();
|
||||
});
|
||||
|
||||
it("formats with colons", () => {
|
||||
expect(formatUid("04a1b2c3")).toBe("04:A1:B2:C3");
|
||||
});
|
||||
|
||||
it("tryNormalizeUid returns null on bad input", () => {
|
||||
expect(tryNormalizeUid("xyz")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user