test: Phase 4 — vitest setup + unit tests for admin-session and rate-limit
Establishes the test suite. The repo previously had zero tests and no test framework installed. Tooling: - Vitest 2 added as devDependency. Chosen for ESM-native + TypeScript out-of-the-box, no Babel/ts-node, and fast cold starts. - vitest.config.ts sets environment=node and wires the '@/' path alias so tests can import app modules by the same path the app uses. - package.json scripts: 'test' (vitest run, CI-friendly) and 'test:watch'; also adds the long-missing 'typecheck' wrapper for 'tsc --noEmit'. - tsconfig.json now excludes *.test.ts from the app's build graph so the production bundle doesn't pull in test files (the editor still type-checks them via vitest). Tests: - admin-session.test.ts (8 cases): sign/verify round-trip for ADMIN and SUPER_ADMIN, tampered payload rejection (privilege-escalation attempt — should be rejected because the HMAC no longer matches), tampered signature rejection, missing-separator token, invalid base64/JSON payload, and cookie option flags (httpOnly, sameSite, path, secure under NODE_ENV=test vs production). - rate-limit.test.ts (4 cases): basic token bucket within window, independent key tracking, refill after window elapses (fake timers), and remaining-counter accounting. 12 tests, all green.
This commit is contained in:
parent
ddd4327a99
commit
1b917540f4
1346
package-lock.json
generated
1346
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -7,6 +7,9 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio",
|
||||
@ -28,7 +31,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^20.19.43",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@ -37,6 +40,7 @@
|
||||
"eslint-config-next": "^15.5.19",
|
||||
"prisma": "^5.22.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
81
src/lib/__tests__/admin-session.test.ts
Normal file
81
src/lib/__tests__/admin-session.test.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import {
|
||||
createAdminSession,
|
||||
verifyAdminSession,
|
||||
COOKIE_NAME_ADMIN,
|
||||
cookieOptions,
|
||||
} from "../admin-session";
|
||||
|
||||
const SECRET = "a".repeat(64);
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.ADMIN_SESSION_SECRET = SECRET;
|
||||
process.env.NEXT_PUBLIC_APP_URL = "https://testbed.mk";
|
||||
});
|
||||
|
||||
describe("admin-session.sign/verify", () => {
|
||||
it("round-trips a valid session", async () => {
|
||||
const token = await createAdminSession({ username: "alice", role: "ADMIN" });
|
||||
const parsed = await verifyAdminSession(token);
|
||||
expect(parsed).toEqual({ username: "alice", role: "ADMIN" });
|
||||
});
|
||||
|
||||
it("round-trips SUPER_ADMIN role", async () => {
|
||||
const token = await createAdminSession({ username: "super", role: "SUPER_ADMIN" });
|
||||
const parsed = await verifyAdminSession(token);
|
||||
expect(parsed).toEqual({ username: "super", role: "SUPER_ADMIN" });
|
||||
});
|
||||
|
||||
it("rejects a tampered payload (signature no longer matches)", async () => {
|
||||
const token = await createAdminSession({ username: "alice", role: "ADMIN" });
|
||||
const [payload, sig] = token.split(".");
|
||||
const tamperedPayload = btoa(
|
||||
JSON.stringify({ username: "alice", role: "SUPER_ADMIN" })
|
||||
);
|
||||
const tampered = `${tamperedPayload}.${sig}`;
|
||||
const parsed = await verifyAdminSession(tampered);
|
||||
expect(parsed).toBeNull();
|
||||
expect(payload).not.toEqual(tamperedPayload);
|
||||
});
|
||||
|
||||
it("rejects a tampered signature", async () => {
|
||||
const token = await createAdminSession({ username: "alice", role: "ADMIN" });
|
||||
const [payload] = token.split(".");
|
||||
const forgedSig = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
const forged = `${payload}.${forgedSig}`;
|
||||
const parsed = await verifyAdminSession(forged);
|
||||
expect(parsed).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a malformed token without separator", async () => {
|
||||
const parsed = await verifyAdminSession("just-a-string-no-separator");
|
||||
expect(parsed).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a token whose payload is not valid base64 JSON", async () => {
|
||||
const fakePayload = btoa("not-json-at-all");
|
||||
const fakeToken = `${fakePayload}.aaaaaaaa`;
|
||||
const parsed = await verifyAdminSession(fakeToken);
|
||||
expect(parsed).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin-session.cookieOptions", () => {
|
||||
it("emits the documented cookie name and secure flags", () => {
|
||||
const opts = cookieOptions("token-value");
|
||||
expect(opts.name).toBe(COOKIE_NAME_ADMIN);
|
||||
expect(opts.value).toBe("token-value");
|
||||
expect(opts.httpOnly).toBe(true);
|
||||
expect(opts.sameSite).toBe("lax");
|
||||
expect(opts.path).toBe("/");
|
||||
});
|
||||
|
||||
it("marks the cookie secure only in production", () => {
|
||||
const prev = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = "production";
|
||||
expect(cookieOptions("x").secure).toBe(true);
|
||||
process.env.NODE_ENV = "test";
|
||||
expect(cookieOptions("x").secure).toBe(false);
|
||||
process.env.NODE_ENV = prev;
|
||||
});
|
||||
});
|
||||
47
src/lib/__tests__/rate-limit.test.ts
Normal file
47
src/lib/__tests__/rate-limit.test.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { RateLimiter } from "../rate-limit";
|
||||
|
||||
describe("RateLimiter", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("allows up to maxTokens requests within a window", () => {
|
||||
const limiter = new RateLimiter(3, 60_000);
|
||||
expect(limiter.limit("k").success).toBe(true);
|
||||
expect(limiter.limit("k").success).toBe(true);
|
||||
expect(limiter.limit("k").success).toBe(true);
|
||||
expect(limiter.limit("k").success).toBe(false);
|
||||
});
|
||||
|
||||
it("tracks keys independently", () => {
|
||||
const limiter = new RateLimiter(2, 60_000);
|
||||
expect(limiter.limit("a").success).toBe(true);
|
||||
expect(limiter.limit("b").success).toBe(true);
|
||||
expect(limiter.limit("a").success).toBe(true);
|
||||
expect(limiter.limit("a").success).toBe(false);
|
||||
expect(limiter.limit("b").success).toBe(true);
|
||||
});
|
||||
|
||||
it("refills after the window elapses", () => {
|
||||
const limiter = new RateLimiter(1, 60_000);
|
||||
expect(limiter.limit("k").success).toBe(true);
|
||||
expect(limiter.limit("k").success).toBe(false);
|
||||
vi.advanceTimersByTime(60_001);
|
||||
expect(limiter.limit("k").success).toBe(true);
|
||||
});
|
||||
|
||||
it("decrements remaining on each success", () => {
|
||||
const limiter = new RateLimiter(3, 60_000);
|
||||
expect(limiter.limit("k").remaining).toBe(2);
|
||||
expect(limiter.limit("k").remaining).toBe(1);
|
||||
expect(limiter.limit("k").remaining).toBe(0);
|
||||
const blocked = limiter.limit("k");
|
||||
expect(blocked.success).toBe(false);
|
||||
expect(blocked.remaining).toBe(0);
|
||||
});
|
||||
});
|
||||
@ -34,9 +34,13 @@
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
"**/*.mts",
|
||||
"vitest.config.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
"node_modules",
|
||||
".next",
|
||||
".next_old",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
|
||||
15
vitest.config.ts
Normal file
15
vitest.config.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
globals: false,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user