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:
dimitar 2026-08-02 13:05:06 +02:00
parent ddd4327a99
commit 1b917540f4
6 changed files with 1498 additions and 7 deletions

1346
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View 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;
});
});

View 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);
});
});

View File

@ -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
View 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,
},
});