Author SHA1 Message Date
Nick TrochalakisandCursor Agent 0e50c537ee Strengthen CI (Next + Docker build) and expand tests (#4)
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.

Co-authored-by: Cursor Agent <[email protected]>
2026-08-23 23:37:09 +00:00
Nick TrochalakisandCursor Agent 5aa5fef9af Fix Dockerfile conflict markers broken on main (#3)
* Fix conflict markers on main and guard CI against them

PR #2 merged before the Dockerfile cleanup landed, leaving <<<<<<<
markers that break docker compose build. Restore clean Docker files
and fail CI if conflict markers appear in the tree.

* Add CI check that fails on merge conflict markers

* Narrow conflict-marker CI check to <<<<<<< and >>>>>>>

---------

Co-authored-by: Cursor Agent <[email protected]>
2026-08-23 23:32:54 +00:00
6 changed files with 183 additions and 42 deletions
-3
View File
@@ -2,7 +2,6 @@ AUTH_SECRET=change-me-to-a-long-random-string
# Optional: path to SQLite file (default ./data/rfid.db)
# RFID_DB_PATH=./data/rfid.db
<<<<<<< HEAD
# Docker Compose published port (default 3000)
# RFID_PORT=3000
@@ -11,8 +10,6 @@ AUTH_SECRET=change-me-to-a-long-random-string
# CREATE_USER_PASSWORD=changeme
# CREATE_USER_NAME=Admin
=======
>>>>>>> origin/main
# Optional OIDC (Authentik, Keycloak, Authelia, etc.)
# AUTH_OIDC_ISSUER=https://sso.example.com/application/o/rfid/
# AUTH_OIDC_CLIENT_ID=
+12
View File
@@ -7,6 +7,7 @@ on:
jobs:
check:
name: lint-typecheck-test-build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -18,3 +19,14 @@ jobs:
- run: npm run lint
- run: npm run typecheck
- run: npm test
- run: npm run build
env:
AUTH_SECRET: ci-build-secret-at-least-32-characters
docker:
name: docker-build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build production image
run: docker build -t rfid-database:ci .
-27
View File
@@ -2,33 +2,21 @@
FROM node:22-bookworm-slim AS deps
WORKDIR /app
<<<<<<< HEAD
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
=======
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
>>>>>>> origin/main
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-bookworm-slim AS builder
WORKDIR /app
<<<<<<< HEAD
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
=======
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
>>>>>>> origin/main
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV AUTH_SECRET=build-time-placeholder-secret-32chars
<<<<<<< HEAD
RUN npm run build \
&& npm prune --omit=dev
=======
RUN npm run build
>>>>>>> origin/main
FROM node:22-bookworm-slim AS runner
WORKDIR /app
@@ -36,7 +24,6 @@ ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV RFID_DB_PATH=/data/rfid.db
ENV PORT=3000
<<<<<<< HEAD
ENV HOSTNAME=0.0.0.0
ENV AUTH_TRUST_HOST=true
@@ -65,18 +52,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS http://127.0.0.1:3000/login >/dev/null || exit 1
ENTRYPOINT ["/entrypoint.sh"]
=======
RUN apt-get update && apt-get install -y libstdc++6 && rm -rf /var/lib/apt/lists/*
RUN mkdir -p /data && chown node:node /data
COPY --from=builder /app/package.json /app/package-lock.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/scripts ./scripts
COPY --from=builder /app/src ./src
COPY --from=builder /app/tsconfig.json ./tsconfig.json
USER node
EXPOSE 3000
VOLUME ["/data"]
>>>>>>> origin/main
CMD ["npm", "run", "start"]
+5 -12
View File
@@ -80,13 +80,16 @@ curl -sH "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
npm test
npm run lint
npm run typecheck
npm run build
```
GitHub Actions runs lint, typecheck, and tests on every pull request and push to `main`.
GitHub Actions runs on every pull request and push to `main`:
1. **lint / typecheck / test / Next.js build**
2. **`docker build`** — catches Dockerfile parse/build failures (the class of break that conflict markers previously caused)
## Docker
<<<<<<< HEAD
### docker compose (recommended)
```bash
@@ -117,26 +120,16 @@ docker compose down
### Plain docker
=======
>>>>>>> origin/main
```bash
docker build -t rfid-database .
docker run --rm -p 3000:3000 \
-e AUTH_SECRET=your-long-secret \
<<<<<<< HEAD
-e CREATE_USER_EMAIL=[email protected] \
-e CREATE_USER_PASSWORD=changeme \
=======
>>>>>>> origin/main
-v rfid-data:/data \
rfid-database
```
<<<<<<< HEAD
=======
Create the first user against the mounted DB (exec into the container or run `create-user` with `RFID_DB_PATH` pointed at the volume).
>>>>>>> origin/main
## Security
Tag dumps often include sector keys. Keep the app behind HTTPS, do not expose it publicly without auth, and treat `data/rfid.db` as sensitive.
+76
View File
@@ -21,6 +21,10 @@ 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]) {
@@ -189,9 +193,81 @@ describe("API v1 integration", () => {
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);
});
});
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { exportTag } from "@/lib/rfid/exporters";
import {
dumpDataSchema,
mifareClassicDumpSchema,
siteCreateSchema,
tagCreateSchema,
} from "@/lib/validation/rfid";
const classicDump = {
size: "1K" as const,
sectors: [
{
index: 0,
blocks: [
"04A1B2C304A1B2C304A1B2C304A1B2C3",
"00000000000000000000000000000000",
"00000000000000000000000000000000",
"FFFFFFFFFFFFFF078069FFFFFFFFFFFF",
],
},
],
};
const sampleTag = {
id: "00000000-0000-4000-8000-000000000001",
label: "Dock fob",
frequency: "HF",
protocol: "MIFARE_CLASSIC_1K",
uid: "04A1B2C3",
dumpData: classicDump,
keys: { A: ["FFFFFFFFFFFF"], B: [] },
notes: null as string | null,
};
describe("exporters", () => {
it("exports canonical JSON", () => {
const out = exportTag(sampleTag, "json");
expect(out.contentType).toBe("application/json");
const parsed = JSON.parse(out.body);
expect(parsed.uid).toBe("04A1B2C3");
expect(parsed.dumpData.size).toBe("1K");
});
it("exports proxmark JSON with blocks", () => {
const out = exportTag(sampleTag, "proxmark");
const parsed = JSON.parse(out.body);
expect(parsed.FileType).toBe("mfcard");
expect(parsed.blocks).toHaveLength(4);
expect(parsed.Card.UID).toBe("04A1B2C3");
});
it("exports MCT text", () => {
const out = exportTag(sampleTag, "mct");
expect(out.body).toContain("+UID: 04A1B2C3");
expect(out.body).toContain("+Sector: 0");
});
it("exports hex listing", () => {
const out = exportTag(sampleTag, "hex");
expect(out.body).toContain("# UID 04A1B2C3");
expect(out.body).toContain("S0B0");
});
});
describe("validation schemas", () => {
it("accepts valid classic dump", () => {
expect(mifareClassicDumpSchema.safeParse(classicDump).success).toBe(true);
expect(dumpDataSchema.safeParse(classicDump).success).toBe(true);
});
it("rejects invalid site codes", () => {
expect(
siteCreateSchema.safeParse({ name: "Lab", code: "bad code!" }).success
).toBe(false);
});
it("accepts valid tag create payload", () => {
const parsed = tagCreateSchema.safeParse({
siteId: "00000000-0000-4000-8000-000000000099",
label: "Dock",
frequency: "HF",
protocol: "MIFARE_CLASSIC_1K",
uid: "04:A1:B2:C3",
dumpData: classicDump,
keys: { A: ["FFFFFFFFFFFF"] },
});
expect(parsed.success).toBe(true);
});
});