v1 softlaunch

This commit is contained in:
2026-05-05 10:39:59 -04:00
parent 6023fe5214
commit 6fd8b0317e
26 changed files with 1070 additions and 10 deletions

83
test/audit.test.ts Normal file
View File

@@ -0,0 +1,83 @@
// Unit tests for the audit helper. Outside a request context (vitest), the
// `headers()` call throws and we should silently fall back to nulls — which
// is exactly what the helper does.
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
import { audit } from "@/lib/audit";
import { seed, type SeedResult } from "@/lib/seed";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const db = new PrismaClient({ adapter });
let fx: SeedResult;
beforeAll(async () => {
fx = await seed(db);
});
afterAll(async () => {
await db.$disconnect();
});
beforeEach(async () => {
await db.auditLog.deleteMany();
});
describe("audit", () => {
test("creates an AuditLog row with the supplied fields", async () => {
await audit(db, {
actorId: fx.admin.id,
action: "test.event",
entityType: "Test",
entityId: "abc",
meta: { foo: "bar" },
});
const rows = await db.auditLog.findMany();
expect(rows).toHaveLength(1);
expect(rows[0].actorId).toBe(fx.admin.id);
expect(rows[0].action).toBe("test.event");
expect(rows[0].entityType).toBe("Test");
expect(rows[0].entityId).toBe("abc");
expect(rows[0].meta).toEqual({ foo: "bar" });
});
test("nulls IP/UA when called outside a request context", async () => {
await audit(db, {
actorId: fx.admin.id,
action: "test.event",
entityType: "Test",
entityId: "abc",
});
const row = await db.auditLog.findFirst();
expect(row?.ip).toBeNull();
expect(row?.ua).toBeNull();
});
test("accepts a null actorId for system events", async () => {
await audit(db, {
actorId: null,
action: "system.heartbeat",
entityType: "System",
entityId: "1",
});
const row = await db.auditLog.findFirst();
expect(row?.actorId).toBeNull();
});
test("does not throw if write fails (best-effort semantics)", async () => {
// Simulate by sending an entityId that violates a column type or similar.
// Easiest: inject a JSON value that's structured to roundtrip cleanly,
// and verify no throw. (Real failure modes are network/disk, not validation.)
await expect(
audit(db, {
actorId: fx.admin.id,
action: "test.event",
entityType: "Test",
entityId: "ok",
meta: { nested: { a: 1, b: [1, 2, 3] } },
}),
).resolves.toBeUndefined();
});
});

50
test/rate-limit.test.ts Normal file
View File

@@ -0,0 +1,50 @@
import { afterEach, describe, expect, test } from "vitest";
import { check, reset } from "@/lib/rate-limit";
afterEach(() => reset());
describe("rate limiter", () => {
test("allows requests under the limit", () => {
for (let i = 0; i < 5; i++) {
const r = check("k", 5, 60_000);
expect(r.ok).toBe(true);
}
});
test("blocks at the limit and reports retry-after", () => {
for (let i = 0; i < 5; i++) check("k", 5, 60_000);
const r = check("k", 5, 60_000);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.retryAfterSec).toBeGreaterThan(0);
expect(r.retryAfterSec).toBeLessThanOrEqual(60);
}
});
test("different keys are independent", () => {
for (let i = 0; i < 5; i++) check("a", 5, 60_000);
const r = check("b", 5, 60_000);
expect(r.ok).toBe(true);
});
test("window resets when expired", () => {
// Use a 10ms window so the test runs fast
for (let i = 0; i < 3; i++) check("k", 3, 10);
const blocked = check("k", 3, 10);
expect(blocked.ok).toBe(false);
return new Promise<void>((done) => {
setTimeout(() => {
const after = check("k", 3, 10);
expect(after.ok).toBe(true);
done();
}, 25);
});
});
test("remaining count counts down", () => {
const r1 = check("k", 3, 60_000);
const r2 = check("k", 3, 60_000);
expect(r1.ok && r1.remaining).toBe(2);
expect(r2.ok && r2.remaining).toBe(1);
});
});