From 864d8989bcb1d5ba6d85a39e9e20c98bd6f81e32 Mon Sep 17 00:00:00 2001 From: Taka <71265675+takakv@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:54:21 +0300 Subject: [PATCH 01/10] Add test-suite prerequisites --- prisma/seed-core.ts | 78 ++++++++++++++++++ prisma/seed.ts | 82 +------------------ src/config/env.ts | 2 + src/lib/guards.ts | 11 ++- src/lib/permissions.ts | 28 ++++--- src/lib/prisma.ts | 1 + src/plugins/app/nodemailer.ts | 5 +- .../registrations/registrations.schemas.ts | 4 +- .../registrations/registrations.service.ts | 21 ++--- src/routes/api/shifts/shifts.schemas.ts | 2 +- 10 files changed, 123 insertions(+), 111 deletions(-) create mode 100644 prisma/seed-core.ts diff --git a/prisma/seed-core.ts b/prisma/seed-core.ts new file mode 100644 index 0000000..aa2a2d1 --- /dev/null +++ b/prisma/seed-core.ts @@ -0,0 +1,78 @@ +import { Permissions } from "#app/constants/permissions"; +import type { RoleName } from "#app/constants/roles"; +import type { PrismaClient } from "#app/generated/prisma/client"; + +const BASE_SHIFT_PERMISSIONS = [ + Permissions.EDIT_SHIFT_BASIC, + Permissions.VIEW_SHIFT_BASIC, + Permissions.VIEW_REGISTRATION_BASIC, + Permissions.VIEW_SHIFT_STAFF, +] as const; + +const ROLE_PERMISSIONS: Record = { + root: [ + ...BASE_SHIFT_PERMISSIONS, + Permissions.VIEW_REGISTRATION_FULL, + Permissions.EDIT_REGISTRATION_PRICE, + Permissions.EDIT_REGISTRATION_IS_REGISTERED, + Permissions.DELETE_REGISTRATION, + Permissions.VIEW_SHIFT_PERMISSIONS, + Permissions.EDIT_SHIFT_MEMBERS, + ], + boss: [ + ...BASE_SHIFT_PERMISSIONS, + Permissions.VIEW_REGISTRATION_PERSONAL_INFO, + Permissions.VIEW_REGISTRATION_PRICE, + Permissions.VIEW_REGISTRATION_CONTACT, + Permissions.EDIT_REGISTRATION_PRICE, + Permissions.EDIT_REGISTRATION_IS_REGISTERED, + Permissions.DELETE_REGISTRATION, + Permissions.VIEW_SHIFT_PERMISSIONS, + Permissions.EDIT_SHIFT_MEMBERS, + ], + instructor: [ + ...BASE_SHIFT_PERMISSIONS, + Permissions.VIEW_REGISTRATION_CONTACT, + ], + helper: [...BASE_SHIFT_PERMISSIONS], + "reg-viewer-basic": [Permissions.VIEW_REGISTRATION_BASIC], +}; + +export const seedRolesAndPermissions = async ( + client: PrismaClient, +): Promise => { + const permissionIds = new Map(); + + const getPermissionId = async (permissionName: Permissions) => { + const cached = permissionIds.get(permissionName); + if (cached !== undefined) return cached; + + const permission = await client.permission.upsert({ + where: { permissionName }, + update: {}, + create: { permissionName }, + }); + permissionIds.set(permissionName, permission.id); + return permission.id; + }; + + for (const roleName of Object.keys(ROLE_PERMISSIONS) as RoleName[]) { + const role = await client.role.upsert({ + where: { roleName }, + update: {}, + create: { roleName }, + }); + + for (const permissionName of ROLE_PERMISSIONS[roleName]) { + const permissionId = await getPermissionId(permissionName); + + await client.rolePermission.upsert({ + where: { + roleId_permissionId: { roleId: role.id, permissionId }, + }, + update: {}, + create: { roleId: role.id, permissionId }, + }); + } + } +}; diff --git a/prisma/seed.ts b/prisma/seed.ts index 7a7b838..a6817ad 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,85 +1,7 @@ import prisma from "#app/lib/prisma"; -import { Permissions } from "#app/constants/permissions"; -import type { RoleName } from "#app/constants/roles"; +import { seedRolesAndPermissions } from "./seed-core"; -const BASE_SHIFT_PERMISSIONS = [ - Permissions.EDIT_SHIFT_BASIC, - Permissions.VIEW_SHIFT_BASIC, - Permissions.VIEW_REGISTRATION_BASIC, - Permissions.VIEW_SHIFT_STAFF, -] as const; - -const ROLE_PERMISSIONS: Record = { - root: [ - ...BASE_SHIFT_PERMISSIONS, - Permissions.VIEW_REGISTRATION_FULL, - Permissions.EDIT_REGISTRATION_PRICE, - Permissions.EDIT_REGISTRATION_IS_REGISTERED, - Permissions.DELETE_REGISTRATION, - Permissions.VIEW_SHIFT_PERMISSIONS, - ], - boss: [ - ...BASE_SHIFT_PERMISSIONS, - Permissions.VIEW_REGISTRATION_PERSONAL_INFO, - Permissions.VIEW_REGISTRATION_PRICE, - Permissions.VIEW_REGISTRATION_CONTACT, - Permissions.EDIT_REGISTRATION_PRICE, - Permissions.EDIT_REGISTRATION_IS_REGISTERED, - Permissions.DELETE_REGISTRATION, - Permissions.VIEW_SHIFT_PERMISSIONS, - ], - instructor: [ - ...BASE_SHIFT_PERMISSIONS, - Permissions.VIEW_REGISTRATION_CONTACT, - ], - helper: [...BASE_SHIFT_PERMISSIONS], - "reg-viewer-basic": [Permissions.VIEW_REGISTRATION_BASIC], -}; - -const upsertRole = (roleName: RoleName) => - prisma.role.upsert({ - where: { roleName }, - update: {}, - create: { roleName }, - }); - -const upsertPermission = (permissionName: Permissions) => - prisma.permission.upsert({ - where: { permissionName }, - update: {}, - create: { permissionName }, - }); - -const main = async () => { - const permissionIds = new Map(); - - const getPermissionId = async (permissionName: Permissions) => { - const cached = permissionIds.get(permissionName); - if (cached !== undefined) return cached; - - const permission = await upsertPermission(permissionName); - permissionIds.set(permissionName, permission.id); - return permission.id; - }; - - for (const roleName of Object.keys(ROLE_PERMISSIONS) as RoleName[]) { - const role = await upsertRole(roleName); - - for (const permissionName of ROLE_PERMISSIONS[roleName]) { - const permissionId = await getPermissionId(permissionName); - - await prisma.rolePermission.upsert({ - where: { - roleId_permissionId: { roleId: role.id, permissionId }, - }, - update: {}, - create: { roleId: role.id, permissionId }, - }); - } - } -}; - -main() +seedRolesAndPermissions(prisma) .catch((error: unknown) => { console.error(error); process.exitCode = 1; diff --git a/src/config/env.ts b/src/config/env.ts index 352b4a2..0e93b85 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -9,6 +9,7 @@ export interface EnvConfig { MAILGUN_API_KEY: string; EMAIL_SERV: string; DATABASE_HOST: string; + DATABASE_PORT: number; DATABASE_USER: string; DATABASE_PASSWORD: string; DATABASE_NAME: string; @@ -34,6 +35,7 @@ export const envSchema: JSONSchemaType = { MAILGUN_API_KEY: { type: "string" }, EMAIL_SERV: { type: "string" }, DATABASE_HOST: { type: "string" }, + DATABASE_PORT: { type: "number", default: 3306 }, DATABASE_USER: { type: "string" }, DATABASE_PASSWORD: { type: "string" }, DATABASE_NAME: { type: "string" }, diff --git a/src/lib/guards.ts b/src/lib/guards.ts index 3f4967c..f79721f 100644 --- a/src/lib/guards.ts +++ b/src/lib/guards.ts @@ -18,8 +18,15 @@ const DEFAULT_MESSAGE = "Puuduvad õigused päringuks."; // body is parsed and validated before preHandler runs, so reading shiftNr from // any of these sources is safe. -const getShiftNr = (request: FastifyRequest, source: ShiftNrSource): number => - (request[source] as { shiftNr: number }).shiftNr; +const getShiftNr = (request: FastifyRequest, source: ShiftNrSource): number => { + const value = (request[source] as { shiftNr?: unknown } | undefined)?.shiftNr; + if (typeof value !== "number" || !Number.isInteger(value)) { + throw new Error( + `Guard misconfiguration: request.${source} does not contain an integer shiftNr`, + ); + } + return value; +}; // 403 with { status:"fail", data:{ permissions: message } } when the check fails. export const requireShiftPermission = diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index b2d522c..c71b6fd 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -143,6 +143,20 @@ export type RegistrationViewFlags = { contact: boolean; }; +export const deriveRegistrationViewFlags = ( + perms: ReadonlySet, +): RegistrationViewFlags => ({ + pii: + perms.has(Permissions.VIEW_REGISTRATION_FULL) || + perms.has(Permissions.VIEW_REGISTRATION_PERSONAL_INFO), + financial: + perms.has(Permissions.VIEW_REGISTRATION_FULL) || + perms.has(Permissions.VIEW_REGISTRATION_PRICE), + contact: + perms.has(Permissions.VIEW_REGISTRATION_FULL) || + perms.has(Permissions.VIEW_REGISTRATION_CONTACT), +}); + export const getRegistrationViewFlags = async ( userId: number, shiftNr: number, @@ -153,17 +167,5 @@ export const getRegistrationViewFlags = async ( PermissionPrefixes.REGISTRATION_VIEW, ); - const pii = - shiftViewPermissions.has(Permissions.VIEW_REGISTRATION_FULL) || - shiftViewPermissions.has(Permissions.VIEW_REGISTRATION_PERSONAL_INFO); - - const financial = - shiftViewPermissions.has(Permissions.VIEW_REGISTRATION_FULL) || - shiftViewPermissions.has(Permissions.VIEW_REGISTRATION_PRICE); - - const contact = - shiftViewPermissions.has(Permissions.VIEW_REGISTRATION_FULL) || - shiftViewPermissions.has(Permissions.VIEW_REGISTRATION_CONTACT); - - return { pii, financial, contact }; + return deriveRegistrationViewFlags(shiftViewPermissions); }; diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 52cadaf..b470e91 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -4,6 +4,7 @@ import "dotenv/config"; const adapter = new PrismaMariaDb({ host: process.env.DATABASE_HOST, + port: Number(process.env.DATABASE_PORT ?? 3306), user: process.env.DATABASE_USER, password: process.env.DATABASE_PASSWORD, database: process.env.DATABASE_NAME, diff --git a/src/plugins/app/nodemailer.ts b/src/plugins/app/nodemailer.ts index 3ff5d08..f1d01a8 100644 --- a/src/plugins/app/nodemailer.ts +++ b/src/plugins/app/nodemailer.ts @@ -18,7 +18,10 @@ const mailerPlugin: FastifyPluginAsync = fp(async (server) => { host: "api.eu.mailgun.net", }; - const transporter = nodemailer.createTransport(mg(config)); + const transporter: Transporter = + server.config.NODE_ENV === "test" + ? nodemailer.createTransport({ jsonTransport: true }) + : nodemailer.createTransport(mg(config)); server.decorate("mailer", transporter); if (server.config.NODE_ENV !== "test") { diff --git a/src/routes/api/registrations/registrations.schemas.ts b/src/routes/api/registrations/registrations.schemas.ts index a31512c..5723b23 100644 --- a/src/routes/api/registrations/registrations.schemas.ts +++ b/src/routes/api/registrations/registrations.schemas.ts @@ -71,8 +71,8 @@ export const PatchRegistrationSchema = Type.Object( { isRegistered: Type.Optional(Type.Boolean()), isOld: Type.Optional(Type.Boolean()), - pricePaid: Type.Optional(Type.Integer()), - priceToPay: Type.Optional(Type.Integer()), + pricePaid: Type.Optional(Type.Integer({ minimum: 0 })), + priceToPay: Type.Optional(Type.Integer({ minimum: 0 })), }, { additionalProperties: false, diff --git a/src/routes/api/registrations/registrations.service.ts b/src/routes/api/registrations/registrations.service.ts index 508bf16..f4bae84 100644 --- a/src/routes/api/registrations/registrations.service.ts +++ b/src/routes/api/registrations/registrations.service.ts @@ -2,7 +2,10 @@ import type { Registration } from "#app/generated/prisma/client"; import prisma from "#app/lib/prisma"; import { getAgeAtDate } from "#app/lib/age"; -import { fetchUserShiftPermissions } from "#app/lib/permissions"; +import { + deriveRegistrationViewFlags, + fetchUserShiftPermissions, +} from "#app/lib/permissions"; import { PermissionPrefixes, Permissions } from "#app/constants/permissions"; import { toggleRecord } from "#app/services/camp-records.service"; @@ -43,17 +46,11 @@ export const fetchRegistrations = async ( return { status: "ok", registrations: [] }; } - const canViewPII = - shiftViewPermissions.has(Permissions.VIEW_REGISTRATION_FULL) || - shiftViewPermissions.has(Permissions.VIEW_REGISTRATION_PERSONAL_INFO); - - const canViewFinancial = - shiftViewPermissions.has(Permissions.VIEW_REGISTRATION_FULL) || - shiftViewPermissions.has(Permissions.VIEW_REGISTRATION_PRICE); - - const canViewContact = - shiftViewPermissions.has(Permissions.VIEW_REGISTRATION_FULL) || - shiftViewPermissions.has(Permissions.VIEW_REGISTRATION_CONTACT); + const { + pii: canViewPII, + financial: canViewFinancial, + contact: canViewContact, + } = deriveRegistrationViewFlags(shiftViewPermissions); const rawRegistrations = await prisma.registration.findMany({ where: { shiftNr, visible: true }, diff --git a/src/routes/api/shifts/shifts.schemas.ts b/src/routes/api/shifts/shifts.schemas.ts index 1380825..15707ef 100644 --- a/src/routes/api/shifts/shifts.schemas.ts +++ b/src/routes/api/shifts/shifts.schemas.ts @@ -12,7 +12,7 @@ export const ShiftTentQuerySchema = Type.Object({ }); export const AddGradeSchema = Type.Object({ - score: Type.Integer(), + score: Type.Integer({ minimum: 0, maximum: 255 }), }); export type UserWithShiftRole = Static; From db568022ae713a94e6607216e1e5a600e9012e74 Mon Sep 17 00:00:00 2001 From: Taka <71265675+takakv@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:56:46 +0300 Subject: [PATCH 02/10] Add test DB compose file and scripts --- compose.test.yml | 17 +++++++++++++++++ package.json | 7 ++++++- test/tsconfig.json | 2 +- 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 compose.test.yml diff --git a/compose.test.yml b/compose.test.yml new file mode 100644 index 0000000..3a4b206 --- /dev/null +++ b/compose.test.yml @@ -0,0 +1,17 @@ +services: + mariadb: + image: mariadb:11.4 + environment: + MARIADB_DATABASE: ml_test + MARIADB_USER: ml_test + MARIADB_PASSWORD: ml_test + MARIADB_ROOT_PASSWORD: root + ports: + - "3307:3306" + tmpfs: + - /var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 5s + timeout: 5s + retries: 12 diff --git a/package.json b/package.json index 008ce14..ae83172 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,12 @@ "start": "node dist/server.js", "seed": "tsx prisma/seed.ts", "lint": "eslint .", - "test": "NODE_ENV=test node --import tsx --test --test-force-exit test/*.test.ts" + "test": "NODE_ENV=test node --import tsx --test --test-concurrency=1 --test-force-exit \"test/**/*.test.ts\"", + "test:db:up": "docker compose -f compose.test.yml up -d --wait", + "test:db:down": "docker compose -f compose.test.yml down -v", + "test:db:push": "DATABASE_URL=mysql://ml_test:ml_test@127.0.0.1:3307/ml_test prisma db push", + "test:full": "yarn test:db:up && yarn test:db:push && yarn gen:email && yarn test", + "typecheck": "tsc -p test/tsconfig.json && tsc -p prisma/tsconfig.json" }, "dependencies": { "@fastify/autoload": "^6.3.1", diff --git a/test/tsconfig.json b/test/tsconfig.json index 36e2e75..9503aab 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -7,5 +7,5 @@ }, "noEmit": true }, - "include": ["."] + "include": [".", "../src"] } From bad83dc11102a582980b5e0af9581319bdc0623a Mon Sep 17 00:00:00 2001 From: Taka <71265675+takakv@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:59:48 +0300 Subject: [PATCH 03/10] Add test helpers --- test/helpers/build.ts | 1 + test/helpers/db.ts | 48 ++++++++++++ test/helpers/fixtures.ts | 160 +++++++++++++++++++++++++++++++++++++++ test/helpers/test-env.ts | 20 +++++ 4 files changed, 229 insertions(+) create mode 100644 test/helpers/db.ts create mode 100644 test/helpers/fixtures.ts create mode 100644 test/helpers/test-env.ts diff --git a/test/helpers/build.ts b/test/helpers/build.ts index 6d6b9ff..4ad2374 100644 --- a/test/helpers/build.ts +++ b/test/helpers/build.ts @@ -1,3 +1,4 @@ +import "./test-env"; import type { FastifyInstance } from "fastify"; import { buildApp } from "#app/app"; diff --git a/test/helpers/db.ts b/test/helpers/db.ts new file mode 100644 index 0000000..318ed78 --- /dev/null +++ b/test/helpers/db.ts @@ -0,0 +1,48 @@ +import "./test-env"; +import prisma from "#app/lib/prisma"; +import { seedRolesAndPermissions } from "../../prisma/seed-core"; + +// Every app table, by its exact @@map name. Order is irrelevant because FK +// checks are disabled during truncation. +const TABLES = [ + "bills", + "certificates", + "children", + "documents", + "event_info", + "general_info", + "permissions", + "records", + "registrations", + "reset_tokens", + "role_permissions", + "roles", + "sessions", + "shift_staff", + "shifts", + "signup_tokens", + "teams", + "tent_scores", + "user_roles", + "users", +] as const; + +// Truncates every app table and re-seeds roles/permissions. Guarded so it can +// only ever run against the dedicated test database. +export const resetDb = async (): Promise => { + if (process.env.DATABASE_NAME !== "ml_test") { + throw new Error( + `resetDb refused: DATABASE_NAME is "${process.env.DATABASE_NAME}", expected "ml_test"`, + ); + } + + await prisma.$executeRawUnsafe("SET FOREIGN_KEY_CHECKS = 0"); + for (const table of TABLES) { + await prisma.$executeRawUnsafe(`TRUNCATE TABLE \`${table}\``); + } + await prisma.$executeRawUnsafe("SET FOREIGN_KEY_CHECKS = 1"); + + await seedRolesAndPermissions(prisma); +}; + +export { prisma }; diff --git a/test/helpers/fixtures.ts b/test/helpers/fixtures.ts new file mode 100644 index 0000000..a00ef8c --- /dev/null +++ b/test/helpers/fixtures.ts @@ -0,0 +1,160 @@ +import "./test-env"; +import { randomUUID } from "node:crypto"; +import assert from "node:assert/strict"; +import bcrypt from "bcrypt"; +import type { FastifyInstance } from "fastify"; + +import { + Prisma, + type Child, + type Registration, + type ShiftInfo, + type User, +} from "#app/generated/prisma/client"; +import type { RoleName } from "#app/constants/roles"; +import prisma from "#app/lib/prisma"; + +export const TEST_PASSWORD = "test-password-123"; + +// Cost 4 keeps the suite fast; bcrypt.compare does not care about the cost. +const passwordHash = bcrypt.hashSync(TEST_PASSWORD, 4); + +const daysFromNowUTCMidnight = (days: number): Date => { + const now = new Date(); + return new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + days), + ); +}; + +// ShiftInfo row. id defaults to shiftNr; startDate to 90 days out at UTC +// midnight. Any field is overridable. +export const createShiftInfo = ( + shiftNr: number, + overrides?: Partial, +): Promise => + prisma.shiftInfo.create({ + data: { + id: shiftNr, + bossName: "Boss", + bossEmail: `boss${shiftNr}@test.invalid`, + bossPhone: "5550000", + length: 12, + startDate: daysFromNowUTCMidnight(90), + ...overrides, + }, + }); + +// User + optional per-shift roles. Password is always TEST_PASSWORD hashed at +// cost 4. `roles` looks up each Role by name and creates UserRoles rows. +// `superRoot` sets User.role = "root". email defaults to +// `${username}@test.invalid` (pass null explicitly for no email); currentShift +// defaults to the first role's shiftNr, or 1. +export const createUser = async (opts: { + username: string; + roles?: { shiftNr: number; roleName: RoleName }[]; + superRoot?: boolean; + email?: string | null; + currentShift?: number; +}): Promise => { + const roles = opts.roles ?? []; + const currentShift = opts.currentShift ?? roles[0]?.shiftNr ?? 1; + const email = + opts.email === undefined ? `${opts.username}@test.invalid` : opts.email; + + const user = await prisma.user.create({ + data: { + username: opts.username, + name: opts.username, + email, + currentShift, + password: passwordHash, + role: opts.superRoot ? "root" : "std", + }, + }); + + for (const { shiftNr, roleName } of roles) { + const role = await prisma.role.findUniqueOrThrow({ where: { roleName } }); + await prisma.userRoles.create({ + data: { userId: user.id, shiftNr, roleId: role.id }, + }); + } + + return user; +}; + +// Maintains a module-level counter that feeds regOrder and the default +// contactEmail so sequential fixtures stay disjoint. +let regCounter = 0; + +// Child + Registration. Registration defaults are all overridable via +// `overrides`; the child is always sex "M" with birthYear derived from the +// registration birthday. +export const createChildWithRegistration = async (opts: { + name: string; + shiftNr: number; + overrides?: Partial; +}): Promise<{ child: Child; registration: Registration }> => { + const counter = ++regCounter; + const overrides = opts.overrides ?? {}; + + const birthday = overrides.birthday + ? new Date(overrides.birthday) + : new Date(Date.UTC(2014, 4, 5)); + + const child = await prisma.child.create({ + data: { + name: opts.name, + sex: "M", + birthYear: birthday.getUTCFullYear(), + }, + }); + + const registration = await prisma.registration.create({ + data: { + childId: child.id, + shiftNr: opts.shiftNr, + regId: randomUUID(), + regOrder: counter, + birthday, + tsSize: "M", + road: "x", + city: "x", + county: "x", + country: "Eesti", + contactName: "Parent", + contactNumber: "5551234", + contactEmail: `parent${counter}@test.invalid`, + isRegistered: false, + isOld: true, + priceToPay: 250, + ...overrides, + }, + }); + + return { child, registration }; +}; + +// Logs in via POST /api/auth/login and returns the cookie header value +// ("sessionId=..."), ready for inject({ headers: { cookie } }). Asserts the +// login itself returned 200. +export const loginAs = async ( + app: FastifyInstance, + username: string, +): Promise => { + const res = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username, password: TEST_PASSWORD }, + }); + assert.equal( + res.statusCode, + 200, + `loginAs(${username}) expected 200 but got ${res.statusCode}: ${res.body}`, + ); + + const cookie = res.cookies.find((c) => c.name === "sessionId"); + if (!cookie) { + throw new Error(`loginAs(${username}) did not set a sessionId cookie`); + } + return `${cookie.name}=${cookie.value}`; +}; diff --git a/test/helpers/test-env.ts b/test/helpers/test-env.ts new file mode 100644 index 0000000..41c5e35 --- /dev/null +++ b/test/helpers/test-env.ts @@ -0,0 +1,20 @@ +// Side-effect module: unconditionally pins the test environment before any +// module that transitively imports `#app/*` is loaded. Assignments are +// unconditional so a developer's `.env`/shell can never point tests at a real +// database (dotenv never overrides already-set vars, in both lib/prisma.ts and +// @fastify/env). Import this FIRST in every helper. +Object.assign(process.env, { + NODE_ENV: "test", + PORT: "0", + APP_URL: "http://app.test.invalid", + COOKIE_SECRET: "test-cookie-secret-0123456789-0123456789", + MAILGUN_API_KEY: "test-key", + EMAIL_SERV: "mail.test.invalid", + DATABASE_HOST: "127.0.0.1", + DATABASE_PORT: "3307", + DATABASE_USER: "ml_test", + DATABASE_PASSWORD: "ml_test", + DATABASE_NAME: "ml_test", +}); +delete process.env.COOKIE_DOMAIN; +delete process.env.DATABASE_URL; From 19154399e67c640e31f2b6364cb6ea39521464c5 Mon Sep 17 00:00:00 2001 From: Taka <71265675+takakv@users.noreply.github.com> Date: Sun, 5 Jul 2026 07:03:24 +0300 Subject: [PATCH 04/10] Add unit tests --- test/unit/age.test.ts | 24 +++++++++++++++++++ test/unit/date.test.ts | 36 ++++++++++++++++++++++++++++ test/unit/guards.test.ts | 32 +++++++++++++++++++++++++ test/unit/id-code.test.ts | 49 ++++++++++++++++++++++++++++++++++++++ test/unit/password.test.ts | 15 ++++++++++++ test/unit/pricing.test.ts | 32 +++++++++++++++++++++++++ 6 files changed, 188 insertions(+) create mode 100644 test/unit/age.test.ts create mode 100644 test/unit/date.test.ts create mode 100644 test/unit/guards.test.ts create mode 100644 test/unit/id-code.test.ts create mode 100644 test/unit/password.test.ts create mode 100644 test/unit/pricing.test.ts diff --git a/test/unit/age.test.ts b/test/unit/age.test.ts new file mode 100644 index 0000000..8160c39 --- /dev/null +++ b/test/unit/age.test.ts @@ -0,0 +1,24 @@ +import "../helpers/test-env"; +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { getAgeAtDate } from "#app/lib/age"; + +const birthday = new Date(Date.UTC(2010, 5, 15)); // 2010-06-15 + +void test("getAgeAtDate: birthday later in the target year (not yet reached)", () => { + // Target 2020-03-01 is before the June birthday, so still 9. + assert.equal(getAgeAtDate(birthday, new Date(Date.UTC(2020, 2, 1))), 9); +}); + +void test("getAgeAtDate: exact birthday counts as the new age", () => { + assert.equal(getAgeAtDate(birthday, new Date(Date.UTC(2020, 5, 15))), 10); +}); + +void test("getAgeAtDate: the day before the birthday is still the old age", () => { + assert.equal(getAgeAtDate(birthday, new Date(Date.UTC(2020, 5, 14))), 9); +}); + +void test("getAgeAtDate: same month but an earlier day is the old age", () => { + assert.equal(getAgeAtDate(birthday, new Date(Date.UTC(2020, 5, 1))), 9); +}); diff --git a/test/unit/date.test.ts b/test/unit/date.test.ts new file mode 100644 index 0000000..ca18881 --- /dev/null +++ b/test/unit/date.test.ts @@ -0,0 +1,36 @@ +import "../helpers/test-env"; +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + startOfUTCDay, + addUTCDays, + subUTCMonths, + formatUTCDate, +} from "#app/lib/date"; + +void test("startOfUTCDay truncates the time component in the UTC frame", () => { + const result = startOfUTCDay(new Date("2023-06-15T13:45:30.500Z")); + assert.equal(result.toISOString(), "2023-06-15T00:00:00.000Z"); +}); + +void test("addUTCDays rolls over a month and year boundary", () => { + const result = addUTCDays(new Date(Date.UTC(2022, 11, 31)), 1); + assert.equal(result.toISOString(), "2023-01-01T00:00:00.000Z"); +}); + +void test("subUTCMonths overflows a non-existent day into the next month", () => { + // Mar 31 minus one month = Feb 31, which the Date constructor rolls forward + // to Mar 3 (2023 is not a leap year). + const result = subUTCMonths(new Date(Date.UTC(2023, 2, 31)), 1); + assert.equal(result.toISOString(), "2023-03-03T00:00:00.000Z"); +}); + +void test("formatUTCDate formats in the UTC frame for an explicit locale", () => { + const result = formatUTCDate(new Date(Date.UTC(2023, 0, 15)), "en-GB", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + assert.equal(result, "15/01/2023"); +}); diff --git a/test/unit/guards.test.ts b/test/unit/guards.test.ts new file mode 100644 index 0000000..1d26000 --- /dev/null +++ b/test/unit/guards.test.ts @@ -0,0 +1,32 @@ +import "../helpers/test-env"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { FastifyReply, FastifyRequest } from "fastify"; + +import { requireShiftPermission } from "#app/lib/guards"; +import { Permissions } from "#app/constants/permissions"; + +// After the §3.3 hardening, a mis-wired guard whose source carries no integer +// shiftNr must throw (surfacing as a 500) rather than silently passing +// `undefined` into the permission query. The throw precedes any DB call. +void test("requireShiftPermission rejects when the source lacks an integer shiftNr", async () => { + // The hook handler declares a `this: FastifyInstance` context; drop it so it + // can be called directly in the test. + const handler = requireShiftPermission( + Permissions.VIEW_SHIFT_BASIC, + "params", + ) as (request: FastifyRequest, reply: FastifyReply) => Promise; + + const request = { + params: {}, + session: { user: { userId: 1 } }, + } as unknown as FastifyRequest; + + // Never actually invoked: getShiftNr throws before the guard touches reply. + const reply = { + status: () => reply, + send: () => reply, + } as unknown as FastifyReply; + + await assert.rejects(() => handler(request, reply)); +}); diff --git a/test/unit/id-code.test.ts b/test/unit/id-code.test.ts new file mode 100644 index 0000000..106e14c --- /dev/null +++ b/test/unit/id-code.test.ts @@ -0,0 +1,49 @@ +import "../helpers/test-env"; +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { parseIdCode } from "#app/routes/api/registrations/create.service"; + +void test("parseIdCode parses a valid male code (leading 5)", () => { + const result = parseIdCode("50505050505"); + assert.deepEqual(result, { + sex: "M", + dob: "2005-05-05T00:00:00.000Z", + }); +}); + +void test("parseIdCode parses a valid female code (leading 6)", () => { + const result = parseIdCode("60505050505"); + assert.deepEqual(result, { + sex: "F", + dob: "2005-05-05T00:00:00.000Z", + }); +}); + +void test("parseIdCode rejects a code whose length is not 11", () => { + assert.ok("error" in parseIdCode("123")); +}); + +void test("parseIdCode rejects a code with non-digit characters", () => { + assert.ok("error" in parseIdCode("5050505050a")); +}); + +void test("parseIdCode rejects an adult leading digit (3)", () => { + assert.ok("error" in parseIdCode("35050505050")); +}); + +void test("parseIdCode rejects an impossible month (13)", () => { + assert.ok("error" in parseIdCode("50513050505")); +}); + +void test("parseIdCode rejects an impossible day (Feb 30)", () => { + assert.ok("error" in parseIdCode("50402300000")); +}); + +void test("parseIdCode accepts a valid leap day (Feb 29, 2004)", () => { + const result = parseIdCode("50402290000"); + assert.deepEqual(result, { + sex: "M", + dob: "2004-02-29T00:00:00.000Z", + }); +}); diff --git a/test/unit/password.test.ts b/test/unit/password.test.ts new file mode 100644 index 0000000..bb37a0e --- /dev/null +++ b/test/unit/password.test.ts @@ -0,0 +1,15 @@ +import "../helpers/test-env"; +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { validatePasswordPolicy } from "#app/lib/password"; + +void test("validatePasswordPolicy: a 7-character password returns a message", () => { + const result = validatePasswordPolicy("1234567"); + assert.equal(typeof result, "string"); + assert.ok(result); +}); + +void test("validatePasswordPolicy: an 8-character password passes (null)", () => { + assert.equal(validatePasswordPolicy("12345678"), null); +}); diff --git a/test/unit/pricing.test.ts b/test/unit/pricing.test.ts new file mode 100644 index 0000000..8be3ac7 --- /dev/null +++ b/test/unit/pricing.test.ts @@ -0,0 +1,32 @@ +import "../helpers/test-env"; +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { computePrice } from "#app/routes/api/registrations/create.service"; +import { SHIFT_PRICES, SENIORITY_DISCOUNTS } from "#app/constants/pricing"; + +// Expected values are derived from the pricing constants so they track any +// future change to those tables rather than being independently hardcoded. +void test("computePrice: shift 1, new camper", () => { + assert.equal(computePrice(1, false), SHIFT_PRICES[0]); +}); + +void test("computePrice: shift 1, returning camper (seniority discount)", () => { + assert.equal(computePrice(1, true), SHIFT_PRICES[0] - SENIORITY_DISCOUNTS[0]); +}); + +void test("computePrice: shift 2, new camper", () => { + assert.equal(computePrice(2, false), SHIFT_PRICES[1]); +}); + +void test("computePrice: shift 2, returning camper (seniority discount)", () => { + assert.equal(computePrice(2, true), SHIFT_PRICES[1] - SENIORITY_DISCOUNTS[1]); +}); + +void test("computePrice: shift 0 is out of range", () => { + assert.equal(computePrice(0, false), -1); +}); + +void test("computePrice: shift 5 is out of range", () => { + assert.equal(computePrice(5, false), -1); +}); From 1796238cdd172e1b2103f207548b2384417f9a41 Mon Sep 17 00:00:00 2001 From: Taka <71265675+takakv@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:35:59 +0300 Subject: [PATCH 05/10] Fix resetDb FK truncation across pooled connections --- test/helpers/db.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/test/helpers/db.ts b/test/helpers/db.ts index 318ed78..fcc4444 100644 --- a/test/helpers/db.ts +++ b/test/helpers/db.ts @@ -36,11 +36,17 @@ export const resetDb = async (): Promise => { ); } - await prisma.$executeRawUnsafe("SET FOREIGN_KEY_CHECKS = 0"); - for (const table of TABLES) { - await prisma.$executeRawUnsafe(`TRUNCATE TABLE \`${table}\``); - } - await prisma.$executeRawUnsafe("SET FOREIGN_KEY_CHECKS = 1"); + // The MariaDB adapter pools connections, and `SET FOREIGN_KEY_CHECKS` is a + // per-connection session flag. Pin every statement to one connection via an + // interactive transaction so the disabled FK checks actually apply to the + // TRUNCATEs (which auto-commit but leave the session flag intact). + await prisma.$transaction(async (tx) => { + await tx.$executeRawUnsafe("SET FOREIGN_KEY_CHECKS = 0"); + for (const table of TABLES) { + await tx.$executeRawUnsafe(`TRUNCATE TABLE \`${table}\``); + } + await tx.$executeRawUnsafe("SET FOREIGN_KEY_CHECKS = 1"); + }); await seedRolesAndPermissions(prisma); }; From b3e5cdab94a22d7ecef4a07d5bdbd2c0683de136 Mon Sep 17 00:00:00 2001 From: Taka <71265675+takakv@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:35:59 +0300 Subject: [PATCH 06/10] Add integration tests --- test/integration/auth.test.ts | 185 +++++++ test/integration/password-reset.test.ts | 146 ++++++ test/integration/permissions.test.ts | 461 ++++++++++++++++++ test/integration/registration-create.test.ts | 145 ++++++ .../registration-visibility.test.ts | 158 ++++++ test/integration/signup.test.ts | 267 ++++++++++ test/integration/validation.test.ts | 102 ++++ 7 files changed, 1464 insertions(+) create mode 100644 test/integration/auth.test.ts create mode 100644 test/integration/password-reset.test.ts create mode 100644 test/integration/permissions.test.ts create mode 100644 test/integration/registration-create.test.ts create mode 100644 test/integration/registration-visibility.test.ts create mode 100644 test/integration/signup.test.ts create mode 100644 test/integration/validation.test.ts diff --git a/test/integration/auth.test.ts b/test/integration/auth.test.ts new file mode 100644 index 0000000..2ba3662 --- /dev/null +++ b/test/integration/auth.test.ts @@ -0,0 +1,185 @@ +import { before, after, test } from "node:test"; +import assert from "node:assert/strict"; +import type { FastifyInstance } from "fastify"; + +import { build } from "../helpers/build"; +import { resetDb } from "../helpers/db"; +import { + TEST_PASSWORD, + createShiftInfo, + createUser, + loginAs, +} from "../helpers/fixtures"; + +interface JsendResponse { + status: string; + data: Record; +} + +let app: FastifyInstance; +let aliceId: number; + +before(async () => { + await resetDb(); + await createShiftInfo(1); + const alice = await createUser({ + username: "alice", + roles: [{ shiftNr: 1, roleName: "boss" }], + }); + aliceId = alice.id; + app = await build(); +}); + +after(async () => { + await app.close(); +}); + +void test("login succeeds and returns the user info with a session cookie", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "alice", password: TEST_PASSWORD }, + }); + assert.equal(res.statusCode, 200); + + const body = res.json(); + assert.equal(body.status, "success"); + assert.equal(body.data.userId, aliceId); + assert.equal(body.data.isRoot, false); + assert.ok((body.data.managedShifts as number[]).includes(1)); + + assert.ok(res.cookies.some((c) => c.name === "sessionId")); +}); + +void test("login normalises the username (trim + lowercase)", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: " ALICE ", password: TEST_PASSWORD }, + }); + assert.equal(res.statusCode, 200); +}); + +void test("login with the wrong password fails without enumeration", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "alice", password: "wrong-password" }, + }); + assert.equal(res.statusCode, 401); + const body = res.json(); + assert.equal(body.status, "fail"); + assert.ok(body.data.message); +}); + +void test("login for an unknown user fails identically", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "nobody", password: "wrong-password" }, + }); + assert.equal(res.statusCode, 401); + const body = res.json(); + assert.equal(body.status, "fail"); + assert.ok(body.data.message); +}); + +void test("GET /api/auth/me returns the user info with a cookie, 401 without", async () => { + const cookie = await loginAs(app, "alice"); + + const withCookie = await app.inject({ + method: "GET", + url: "/api/auth/me", + headers: { cookie }, + }); + assert.equal(withCookie.statusCode, 200); + const body = withCookie.json(); + assert.equal(body.data.userId, aliceId); + assert.ok("managedShifts" in body.data); + + const withoutCookie = await app.inject({ method: "GET", url: "/api/auth/me" }); + assert.equal(withoutCookie.statusCode, 401); +}); + +void test("logout invalidates the session", async () => { + const cookie = await loginAs(app, "alice"); + + const logout = await app.inject({ + method: "POST", + url: "/api/auth/logout", + headers: { cookie }, + }); + assert.equal(logout.statusCode, 204); + + const me = await app.inject({ + method: "GET", + url: "/api/auth/me", + headers: { cookie }, + }); + assert.equal(me.statusCode, 401); +}); + +// Kept last: it changes alice's password, after which TEST_PASSWORD no longer +// works for her. +void test("password change: validation, session handling, and re-login", async () => { + const cookieA = await loginAs(app, "alice"); + const cookieB = await loginAs(app, "alice"); + const newPassword = "new-password-456"; + + // Wrong current password. + const wrong = await app.inject({ + method: "POST", + url: "/api/auth/password", + headers: { cookie: cookieA }, + payload: { currentPassword: "not-it", password: newPassword }, + }); + assert.equal(wrong.statusCode, 401); + + // New password too short. + const weak = await app.inject({ + method: "POST", + url: "/api/auth/password", + headers: { cookie: cookieA }, + payload: { currentPassword: TEST_PASSWORD, password: "1234567" }, + }); + assert.equal(weak.statusCode, 422); + + // Valid change. + const ok = await app.inject({ + method: "POST", + url: "/api/auth/password", + headers: { cookie: cookieA }, + payload: { currentPassword: TEST_PASSWORD, password: newPassword }, + }); + assert.equal(ok.statusCode, 204); + + // The acting session (A) is kept; the other session (B) is invalidated. + const meA = await app.inject({ + method: "GET", + url: "/api/auth/me", + headers: { cookie: cookieA }, + }); + assert.equal(meA.statusCode, 200); + + const meB = await app.inject({ + method: "GET", + url: "/api/auth/me", + headers: { cookie: cookieB }, + }); + assert.equal(meB.statusCode, 401); + + // The new password works; the old one does not. + const newLogin = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "alice", password: newPassword }, + }); + assert.equal(newLogin.statusCode, 200); + + const oldLogin = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "alice", password: TEST_PASSWORD }, + }); + assert.equal(oldLogin.statusCode, 401); +}); diff --git a/test/integration/password-reset.test.ts b/test/integration/password-reset.test.ts new file mode 100644 index 0000000..f8bf493 --- /dev/null +++ b/test/integration/password-reset.test.ts @@ -0,0 +1,146 @@ +import { before, after, test } from "node:test"; +import assert from "node:assert/strict"; +import type { FastifyInstance } from "fastify"; + +import { build } from "../helpers/build"; +import { resetDb, prisma } from "../helpers/db"; +import { createShiftInfo, createUser, loginAs } from "../helpers/fixtures"; + +interface JsendResponse { + status: string; + data: Record; +} + +let app: FastifyInstance; +let carolId: number; +let carolSession: string; + +before(async () => { + await resetDb(); + await createShiftInfo(1); + const carol = await createUser({ + username: "carol", + email: "carol@test.invalid", + roles: [{ shiftNr: 1, roleName: "boss" }], + }); + carolId = carol.id; + app = await build(); + + // A pre-existing session that must be invalidated once the password is reset. + carolSession = await loginAs(app, "carol"); +}); + +after(async () => { + await app.close(); +}); + +void test("request for an unknown email is 202 with no token created", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/account/password-reset", + payload: { email: "stranger@test.invalid" }, + }); + assert.equal(res.statusCode, 202); + + const count = await prisma.resetToken.count(); + assert.equal(count, 0); +}); + +void test("request for a known email is 202 and creates a token", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/account/password-reset", + payload: { email: "carol@test.invalid" }, + }); + assert.equal(res.statusCode, 202); + + const token = await prisma.resetToken.findFirst({ + where: { userId: carolId }, + }); + assert.ok(token, "a reset token row exists for carol"); +}); + +void test("confirm with a bad token is forbidden", async () => { + const res = await app.inject({ + method: "PUT", + url: "/api/account/password", + payload: { token: "not-a-real-token", password: "brand-new-pass-1" }, + }); + assert.equal(res.statusCode, 403); +}); + +void test("confirm with a weak password is 422 and keeps the token", async () => { + const token = await prisma.resetToken.findFirstOrThrow({ + where: { userId: carolId }, + }); + + const res = await app.inject({ + method: "PUT", + url: "/api/account/password", + payload: { token: token.token, password: "1234567" }, + }); + assert.equal(res.statusCode, 422); + assert.ok(res.json().data.password); + + const stillThere = await prisma.resetToken.findUnique({ + where: { token: token.token }, + }); + assert.ok(stillThere, "the token survives a weak-password attempt"); +}); + +void test("confirm with a good password resets it and clears tokens + sessions", async () => { + const token = await prisma.resetToken.findFirstOrThrow({ + where: { userId: carolId }, + }); + + const res = await app.inject({ + method: "PUT", + url: "/api/account/password", + payload: { token: token.token, password: "brand-new-pass-1" }, + }); + assert.equal(res.statusCode, 204); + + // The new password works. + const login = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "carol", password: "brand-new-pass-1" }, + }); + assert.equal(login.statusCode, 200); + + // All of carol's reset tokens are gone. + const remaining = await prisma.resetToken.count({ + where: { userId: carolId }, + }); + assert.equal(remaining, 0); + + // The pre-existing session was invalidated. + const me = await app.inject({ + method: "GET", + url: "/api/auth/me", + headers: { cookie: carolSession }, + }); + assert.equal(me.statusCode, 401); +}); + +void test("confirm with an expired token is forbidden and deletes the token", async () => { + const expired = await prisma.resetToken.create({ + data: { + token: crypto.randomUUID(), + userId: carolId, + createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000), + }, + }); + + const res = await app.inject({ + method: "PUT", + url: "/api/account/password", + payload: { token: expired.token, password: "another-new-pass-1" }, + }); + assert.equal(res.statusCode, 403); + + const gone = await prisma.resetToken.findUnique({ + where: { token: expired.token }, + }); + assert.equal(gone, null); +}); diff --git a/test/integration/permissions.test.ts b/test/integration/permissions.test.ts new file mode 100644 index 0000000..7574338 --- /dev/null +++ b/test/integration/permissions.test.ts @@ -0,0 +1,461 @@ +import { before, after, test } from "node:test"; +import assert from "node:assert/strict"; +import type { FastifyInstance } from "fastify"; + +import { build } from "../helpers/build"; +import { resetDb, prisma } from "../helpers/db"; +import { + createShiftInfo, + createUser, + createChildWithRegistration, + loginAs, +} from "../helpers/fixtures"; + +const YEAR = new Date().getUTCFullYear(); + +let app: FastifyInstance; + +const cookies: Record = {}; + +// Fixture ids captured for dynamic URLs. +let bossId = 0; +let reserveRegId = 0; +let recordId = 0; +let tentScoreId = 0; +let billChildId = 0; +let billRegId = 0; + +// Captured while the matrix runs (the bill POST assigns an autoincrement id). +let capturedBillNr = 999999; + +before(async () => { + await resetDb(); + await createShiftInfo(1); + await createShiftInfo(2); + + const superroot = await createUser({ + username: "superroot", + superRoot: true, + roles: [{ shiftNr: 1, roleName: "boss" }], + }); + const boss = await createUser({ + username: "boss", + roles: [{ shiftNr: 1, roleName: "boss" }], + }); + await createUser({ + username: "instructor", + roles: [{ shiftNr: 1, roleName: "instructor" }], + }); + await createUser({ + username: "helper", + roles: [ + { shiftNr: 1, roleName: "helper" }, + { shiftNr: 99, roleName: "helper" }, + ], + }); + await createUser({ + username: "viewer", + roles: [{ shiftNr: 1, roleName: "reg-viewer-basic" }], + }); + await createUser({ + username: "outsider", + roles: [{ shiftNr: 2, roleName: "boss" }], + }); + await createUser({ username: "norole" }); + + bossId = boss.id; + void superroot; + + // The billable, registered camper. + const bill = await createChildWithRegistration({ + name: "Bill Camper", + shiftNr: 1, + overrides: { + contactEmail: "bill-parent@test.invalid", + isRegistered: true, + priceToPay: 340, + }, + }); + billChildId = bill.child.id; + billRegId = bill.registration.id; + + // A reserve (unregistered) camper; the mutating registration PATCH rows + // target this one so the billable camper stays stable. + const reserve = await createChildWithRegistration({ + name: "Reserve Camper", + shiftNr: 1, + }); + reserveRegId = reserve.registration.id; + + const record = await prisma.record.create({ + data: { + childId: bill.child.id, + shiftNr: 1, + year: YEAR, + ageAtCamp: 12, + isActive: true, + }, + }); + recordId = record.id; + + await prisma.team.create({ + data: { shiftNr: 1, name: "Alpha", year: YEAR }, + }); + + const tentScore = await prisma.tentScore.create({ + data: { shiftNr: 1, tentNr: 1, score: 5, year: YEAR }, + }); + tentScoreId = tentScore.id; + + app = await build(); + + for (const username of [ + "superroot", + "boss", + "instructor", + "helper", + "viewer", + "outsider", + "norole", + ]) { + cookies[username] = await loginAs(app, username); + } +}); + +after(async () => { + await app.close(); +}); + +type InjectResponse = Awaited>; + +type Method = "GET" | "POST" | "PATCH" | "DELETE"; + +type Expectation = [as: string, status: number]; + +interface MatrixCase { + method: Method; + url: string | (() => string); + body?: object; + as: string; + expect: number; + capture?: (res: InjectResponse) => void; + assertEmptyBody?: boolean; +} + +const cases: MatrixCase[] = []; + +const add = ( + method: Method, + url: string | (() => string), + body: object | undefined, + expectations: Expectation[], + opts: Pick = {}, +): void => { + for (const [as, expect] of expectations) { + cases.push({ method, url, body, as, expect, ...opts }); + } +}; + +const captureBillNr = (res: InjectResponse): void => { + if (res.statusCode === 201) { + capturedBillNr = res.json<{ data: { billNr: number } }>().data.billNr; + } +}; + +// GET /api/shifts — no guard, everyone authenticated gets 200. +add("GET", "/api/shifts", undefined, [ + ["superroot", 200], + ["boss", 200], + ["instructor", 200], + ["helper", 200], + ["viewer", 200], + ["outsider", 200], + ["norole", 200], +]); + +add("GET", "/api/shifts/1/users", undefined, [ + ["boss", 200], + ["instructor", 403], + ["helper", 403], + ["viewer", 403], + ["outsider", 403], + ["norole", 403], +]); + +add("GET", "/api/shifts/1/billing", undefined, [ + ["boss", 200], + ["instructor", 403], + ["helper", 403], + ["viewer", 403], + ["outsider", 403], +]); + +add("GET", "/api/shifts/1/records", undefined, [ + ["boss", 200], + ["instructor", 200], + ["helper", 200], + ["viewer", 403], + ["outsider", 403], + ["norole", 403], +]); + +add("GET", "/api/shifts/1/emails", undefined, [ + ["boss", 200], + ["instructor", 200], + ["helper", 403], + ["viewer", 403], + ["outsider", 403], +]); + +add("GET", "/api/shifts/1/staff", undefined, [ + ["boss", 200], + ["instructor", 200], + ["helper", 200], + ["viewer", 403], + ["outsider", 403], +]); + +add("GET", "/api/shifts/1/pdf", undefined, [ + ["boss", 200], + ["instructor", 403], + ["helper", 403], + ["viewer", 403], + ["outsider", 403], +]); + +add("GET", "/api/shifts/1/tents", undefined, [ + ["boss", 200], + ["instructor", 200], + ["helper", 200], + ["viewer", 403], + ["outsider", 403], +]); + +add("GET", "/api/shifts/1/tents/1", undefined, [ + ["boss", 200], + ["helper", 200], + ["viewer", 403], + ["outsider", 403], +]); + +add("POST", "/api/shifts/1/tents/2", { score: 5 }, [ + ["boss", 201], + ["instructor", 201], + ["helper", 201], + ["viewer", 403], + ["outsider", 403], + ["norole", 403], +]); + +add("GET", "/api/teams?shiftNr=1", undefined, [ + ["boss", 200], + ["helper", 200], + ["viewer", 403], + ["outsider", 403], +]); + +add("POST", "/api/teams", { shiftNr: 1, name: "Uus" }, [ + ["helper", 201], + ["viewer", 403], + ["outsider", 403], +]); + +add("GET", "/api/registrations?shiftNr=1", undefined, [ + ["superroot", 200], + ["boss", 200], + ["instructor", 200], + ["helper", 200], + ["viewer", 200], + ["outsider", 200], + ["norole", 200], +]); + +// Mutating registration rows: target the reserve registration. +add("PATCH", () => `/api/registrations/${reserveRegId}`, { isRegistered: true }, [ + ["boss", 204], + ["instructor", 404], + ["helper", 404], + ["viewer", 404], + ["outsider", 404], + ["norole", 404], +]); + +add("PATCH", () => `/api/registrations/${reserveRegId}`, { pricePaid: 10 }, [ + ["boss", 204], +]); + +add( + "POST", + "/api/registrations/sync", + undefined, + [ + ["superroot", 204], + ["boss", 403], + ["helper", 403], + ["norole", 403], + ], + { assertEmptyBody: true }, +); + +add( + "POST", + "/api/bills", + { email: "bill-parent@test.invalid" }, + [ + ["boss", 201], + ["instructor", 403], + ["helper", 403], + ["viewer", 403], + ["norole", 403], + ], + { capture: captureBillNr }, +); + +add("GET", () => `/api/bills/${capturedBillNr}`, undefined, [ + ["boss", 200], + ["helper", 403], +]); + +add("GET", "/api/bills/999999", undefined, [["boss", 404]]); + +add("POST", "/api/notifications/bills", { email: "bill-parent@test.invalid" }, [ + ["boss", 204], + ["instructor", 403], + ["helper", 403], +]); + +add("POST", "/api/notifications/bills", { email: "nobody@test.invalid" }, [ + ["boss", 404], +]); + +add("POST", "/api/records", { shiftNr: 1, forceSync: true }, [ + ["boss", 204], + ["instructor", 204], + ["helper", 204], + ["viewer", 403], + ["outsider", 403], + ["norole", 403], +]); + +add("POST", "/api/records", { shiftNr: 1, forceSync: false }, [["boss", 304]]); + +add("POST", "/api/records", { shiftNr: 99, forceSync: true }, [ + ["boss", 403], + ["helper", 404], +]); + +add("PATCH", () => `/api/records/${recordId}`, { tentNr: 3 }, [ + ["boss", 204], + ["instructor", 204], + ["helper", 204], + ["viewer", 403], + ["outsider", 403], + ["norole", 403], +]); + +add("PATCH", "/api/records/999999", { tentNr: 3 }, [["boss", 404]]); + +// Denials must run before the successful delete removes the grade. +add("DELETE", () => `/api/grades/${tentScoreId}`, undefined, [ + ["viewer", 403], + ["outsider", 403], + ["helper", 204], +]); + +add("DELETE", "/api/grades/999999", undefined, [["boss", 204]]); + +add("PATCH", () => `/api/users/${bossId}`, { currentShift: 1 }, [["boss", 204]]); + +add("PATCH", () => `/api/users/${bossId}`, { currentShift: 1 }, [ + ["instructor", 403], +]); + +add("PATCH", () => `/api/users/${bossId}`, { currentShift: 2 }, [["boss", 403]]); + +add("GET", () => `/api/records?childId=${billChildId}`, undefined, [ + ["boss", 200], + ["instructor", 200], + ["helper", 200], + ["outsider", 403], + ["norole", 403], +]); + +add( + "POST", + "/api/users/invites", + { email: "x@test.invalid", name: "X", shiftNr: 1, role: "helper" }, + [ + ["boss", 204], + ["instructor", 403], + ["helper", 403], + ["outsider", 403], + ], +); + +void test("permission matrix", async (t) => { + for (const c of cases) { + const url = typeof c.url === "function" ? c.url() : c.url; + await t.test(`${c.method} ${url} as ${c.as} -> ${c.expect}`, async () => { + const res = await app.inject({ + method: c.method, + url, + payload: c.body, + headers: { cookie: cookies[c.as] }, + }); + assert.equal(res.statusCode, c.expect); + if (c.assertEmptyBody && res.statusCode === 403) { + assert.equal(res.body, ""); + } + c.capture?.(res); + }); + } +}); + +void test("a protected route without a cookie is 401 with a message", async () => { + const res = await app.inject({ method: "GET", url: "/api/shifts/1/users" }); + assert.equal(res.statusCode, 401); + const body = res.json<{ status: string; data: { message: string } }>(); + assert.equal(body.status, "fail"); + assert.ok(body.data.message); +}); + +void test("an unknown path is 404 with data.path", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/nonsense", + headers: { cookie: cookies.boss }, + }); + assert.equal(res.statusCode, 404); + const body = res.json<{ status: string; data: { path: string } }>(); + assert.equal(body.status, "fail"); + assert.ok(body.data.path); +}); + +void test("norole gets an empty registration array (frozen quirk)", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/registrations?shiftNr=1", + headers: { cookie: cookies.norole }, + }); + assert.equal(res.statusCode, 200); + const body = res.json<{ data: { registrations: unknown[] } }>(); + assert.deepEqual(body.data.registrations, []); +}); + +void test("boss can download the shift PDF as a non-empty application/pdf", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/shifts/1/pdf", + headers: { cookie: cookies.boss }, + }); + assert.equal(res.statusCode, 200); + assert.ok(res.headers["content-type"]?.toString().startsWith("application/pdf")); + assert.ok(res.rawPayload.length > 0); +}); + +void test("the bill notification marked the registration as notified", async () => { + const reg = await prisma.registration.findUniqueOrThrow({ + where: { id: billRegId }, + }); + assert.equal(reg.notifSent, true); +}); diff --git a/test/integration/registration-create.test.ts b/test/integration/registration-create.test.ts new file mode 100644 index 0000000..aa5d629 --- /dev/null +++ b/test/integration/registration-create.test.ts @@ -0,0 +1,145 @@ +import "../helpers/test-env"; +import { before, after, test } from "node:test"; +import assert from "node:assert/strict"; +import type { FastifyInstance } from "fastify"; + +import { build } from "../helpers/build"; +import { resetDb, prisma } from "../helpers/db"; +import { createShiftInfo } from "../helpers/fixtures"; +import { computePrice } from "#app/routes/api/registrations/create.service"; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const DOB = new Date(Date.UTC(2015, 0, 2)).toISOString(); + +let app: FastifyInstance; + +const baseEntry = { + name: "Test Child", + shiftNr: 1, + isNew: true, + shirtSize: "M", + road: "Road 1", + city: "City", + county: "County", + country: "Eesti", + contactName: "Parent", + contactEmail: "creator@test.invalid", + contactNumber: "5551111", +}; + +const entry = (overrides: Record): Record => ({ + ...baseEntry, + ...overrides, +}); + +const post = (payload: unknown[]) => + app.inject({ method: "POST", url: "/api/registrations", payload }); + +interface CreateResponse { + status: string; + data: { registrationId?: string } & Record; +} + +before(async () => { + await resetDb(); + await createShiftInfo(1); + await createShiftInfo(2); + app = await build(); +}); + +after(async () => { + await app.close(); +}); + +void test("creates a registration from a valid ID code", async () => { + const idCode = "51501020003"; // male, 2015-01-02 + const res = await post([entry({ name: "Idcode Kid", idCode })]); + assert.equal(res.statusCode, 201); + + const body = res.json(); + assert.equal(body.status, "success"); + assert.match(body.data.registrationId ?? "", UUID_RE); + + const child = await prisma.child.findUniqueOrThrow({ where: { idCode } }); + assert.equal(child.sex, "M"); + assert.equal(child.birthYear, 2015); + + const reg = await prisma.registration.findFirstOrThrow({ + where: { regId: body.data.registrationId }, + }); + // isNew: true -> isOld: false. + assert.equal(reg.priceToPay, computePrice(1, false)); +}); + +void test("creates a registration from explicit sex and dob", async () => { + const res = await post([entry({ name: "Sexdob Kid", sex: "F", dob: DOB })]); + assert.equal(res.statusCode, 201); +}); + +void test("an invalid ID code is a 400 and rolls the transaction back", async () => { + const before = await prisma.child.count(); + const res = await post([ + entry({ name: "Rollback Kid", idCode: "51513020003" }), // month 13 + ]); + assert.equal(res.statusCode, 400); + + const body = res.json(); + assert.equal(body.status, "fail"); + assert.ok("[0].idCode" in body.data); + + const after = await prisma.child.count(); + assert.equal(after, before, "no child row was created"); +}); + +void test("missing sex+dob without an ID code is a 400", async () => { + const res = await post([entry({ name: "Nodata Kid" })]); + assert.equal(res.statusCode, 400); + + const body = res.json(); + assert.equal(body.status, "fail"); + assert.ok("[0].sex" in body.data); + assert.ok("[0].dob" in body.data); +}); + +void test("a duplicate child for the same shift is hidden, not leaked", async () => { + const idCode = "51306150007"; // 2013-06-15 + const first = await post([entry({ name: "Dupe Kid", idCode })]); + assert.equal(first.statusCode, 201); + const second = await post([entry({ name: "Dupe Kid", idCode })]); + assert.equal(second.statusCode, 201); + + const regs = await prisma.registration.findMany({ + where: { idCode }, + orderBy: { id: "asc" }, + }); + assert.equal(regs.length, 2); + assert.equal(regs[0].visible, true); + assert.equal(regs[1].visible, false); +}); + +void test("more than four entries is rejected by the schema", async () => { + const payload = Array.from({ length: 5 }, (_, i) => + entry({ name: `Bulk ${i}`, sex: "M", dob: DOB }), + ); + const res = await post(payload); + assert.equal(res.statusCode, 400); + assert.equal(res.json().status, "fail"); +}); + +void test("sequential registrations get strictly increasing regOrder", async () => { + const r1 = await post([entry({ name: "Order A", sex: "M", dob: DOB })]); + const r2 = await post([entry({ name: "Order B", sex: "M", dob: DOB })]); + assert.equal(r1.statusCode, 201); + assert.equal(r2.statusCode, 201); + + const id1 = r1.json().data.registrationId; + const id2 = r2.json().data.registrationId; + + const reg1 = await prisma.registration.findFirstOrThrow({ + where: { regId: id1 }, + }); + const reg2 = await prisma.registration.findFirstOrThrow({ + where: { regId: id2 }, + }); + assert.ok(reg2.regOrder > reg1.regOrder); +}); diff --git a/test/integration/registration-visibility.test.ts b/test/integration/registration-visibility.test.ts new file mode 100644 index 0000000..b22fc9e --- /dev/null +++ b/test/integration/registration-visibility.test.ts @@ -0,0 +1,158 @@ +import { before, after, test } from "node:test"; +import assert from "node:assert/strict"; +import type { FastifyInstance } from "fastify"; + +import { build } from "../helpers/build"; +import { resetDb } from "../helpers/db"; +import { + createShiftInfo, + createUser, + createChildWithRegistration, + loginAs, +} from "../helpers/fixtures"; + +interface Registration { + id: number; + childId: number; + child: { name: string; sex: string; currentAge: number }; + shiftNr: number; + isRegistered: boolean; + regOrder: number; + isOld: boolean; + tsSize: string; + [key: string]: unknown; +} + +let app: FastifyInstance; + +const fetchFirst = async (username: string): Promise => { + const cookie = await loginAs(app, username); + const res = await app.inject({ + method: "GET", + url: "/api/registrations?shiftNr=1", + headers: { cookie }, + }); + assert.equal(res.statusCode, 200); + const body = res.json<{ data: { registrations: Registration[] } }>(); + assert.ok(body.data.registrations.length > 0); + return body.data.registrations[0]; +}; + +before(async () => { + await resetDb(); + await createShiftInfo(1); + await createUser({ + username: "boss", + roles: [{ shiftNr: 1, roleName: "boss" }], + }); + await createUser({ + username: "instructor", + roles: [{ shiftNr: 1, roleName: "instructor" }], + }); + await createUser({ + username: "helper", + roles: [{ shiftNr: 1, roleName: "helper" }], + }); + await createUser({ + username: "viewer", + roles: [{ shiftNr: 1, roleName: "reg-viewer-basic" }], + }); + + await createChildWithRegistration({ + name: "Visible Camper", + shiftNr: 1, + overrides: { + isRegistered: true, + addendum: "A note", + backupTel: "5559999", + }, + }); + + app = await build(); +}); + +after(async () => { + await app.close(); +}); + +const BASIC_FIELDS = [ + "id", + "childId", + "shiftNr", + "isRegistered", + "regOrder", + "isOld", + "tsSize", +] as const; + +const assertBasics = (reg: Registration): void => { + for (const field of BASIC_FIELDS) { + assert.ok(field in reg, `${field} present`); + } + assert.equal(typeof reg.child.name, "string"); + assert.ok(reg.child.sex === "M" || reg.child.sex === "F"); + assert.ok(Number.isInteger(reg.child.currentAge)); +}; + +void test("boss sees every permission-gated field", async () => { + const reg = await fetchFirst("boss"); + assertBasics(reg); + for (const field of [ + "birthday", + "road", + "county", + "country", + "addendum", + "pricePaid", + "priceToPay", + "notifSent", + "billId", + "contactName", + "contactNumber", + "contactEmail", + "backupTel", + ]) { + assert.ok(field in reg, `boss should see ${field}`); + } +}); + +void test("instructor sees contact fields only", async () => { + const reg = await fetchFirst("instructor"); + assertBasics(reg); + assert.ok("contactName" in reg); + assert.ok("contactEmail" in reg); + assert.ok("contactNumber" in reg); + for (const field of ["birthday", "road", "pricePaid", "priceToPay", "billId"]) { + assert.ok(!(field in reg), `instructor should not see ${field}`); + } +}); + +void test("helper sees basic fields only", async () => { + const reg = await fetchFirst("helper"); + assertBasics(reg); + for (const field of [ + "birthday", + "road", + "county", + "country", + "addendum", + "pricePaid", + "priceToPay", + "notifSent", + "billId", + "contactName", + "contactNumber", + "contactEmail", + "backupTel", + ]) { + assert.ok(!(field in reg), `helper should not see ${field}`); + } +}); + +void test("viewer sees basic fields only", async () => { + const reg = await fetchFirst("viewer"); + assertBasics(reg); + for (const field of ["birthday", "road", "pricePaid", "contactEmail"]) { + assert.ok(!(field in reg), `viewer should not see ${field}`); + } +}); diff --git a/test/integration/signup.test.ts b/test/integration/signup.test.ts new file mode 100644 index 0000000..3f29f9c --- /dev/null +++ b/test/integration/signup.test.ts @@ -0,0 +1,267 @@ +import { before, after, test } from "node:test"; +import assert from "node:assert/strict"; +import type { FastifyInstance } from "fastify"; + +import { build } from "../helpers/build"; +import { resetDb, prisma } from "../helpers/db"; +import { TEST_PASSWORD, createShiftInfo, createUser } from "../helpers/fixtures"; + +interface JsendResponse { + status: string; + data: Record; +} + +let app: FastifyInstance; +let bossCookie: string; +let helperCookie: string; + +before(async () => { + await resetDb(); + await createShiftInfo(1); + await createUser({ + username: "boss1", + roles: [{ shiftNr: 1, roleName: "boss" }], + }); + await createUser({ + username: "helper1", + roles: [{ shiftNr: 1, roleName: "helper" }], + }); + app = await build(); + + bossCookie = await login("boss1"); + helperCookie = await login("helper1"); +}); + +after(async () => { + await app.close(); +}); + +const login = async (username: string): Promise => { + const res = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username, password: TEST_PASSWORD }, + }); + assert.equal(res.statusCode, 200); + const cookie = res.cookies.find((c) => c.name === "sessionId"); + assert.ok(cookie); + return `${cookie.name}=${cookie.value}`; +}; + +void test("invite: boss creates a signup token and staff row", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/users/invites", + headers: { cookie: bossCookie }, + payload: { + email: "new@test.invalid", + name: "New Person", + shiftNr: 1, + role: "instructor", + }, + }); + assert.equal(res.statusCode, 204); + + const token = await prisma.signupToken.findFirst({ + where: { email: "new@test.invalid" }, + }); + assert.ok(token, "a signup token row exists for the invited email"); + assert.ok(token.roleId, "the token carries a roleId"); + + const staff = await prisma.shiftStaff.findFirst({ + where: { name: "New Person", shiftNr: 1 }, + }); + assert.ok(staff, "a shift_staff row exists for the invited person"); + assert.equal(staff.role, "full"); +}); + +void test("invite: an unknown role value is rejected", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/users/invites", + headers: { cookie: bossCookie }, + payload: { + email: "other@test.invalid", + name: "Other Person", + shiftNr: 1, + role: "sultan", + }, + }); + assert.equal(res.statusCode, 422); + const body = res.json(); + assert.equal(body.status, "fail"); + assert.ok(body.data.role); +}); + +void test("signup: consumes the token, creates the user and role", async () => { + const token = await prisma.signupToken.findFirstOrThrow({ + where: { email: "new@test.invalid" }, + }); + + const res = await app.inject({ + method: "POST", + url: "/api/auth/signup", + payload: { + username: "newbie", + email: "new@test.invalid", + name: "New Person", + password: "longenough1", + token: token.token, + }, + }); + assert.equal(res.statusCode, 201); + + // The new user can log in. + const login = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "newbie", password: "longenough1" }, + }); + assert.equal(login.statusCode, 200); + + const user = await prisma.user.findUniqueOrThrow({ + where: { username: "newbie" }, + }); + const userRole = await prisma.userRoles.findFirst({ + where: { userId: user.id, shiftNr: 1 }, + include: { role: true }, + }); + assert.ok(userRole); + assert.equal(userRole.role.roleName, "instructor"); + + const consumed = await prisma.signupToken.findUniqueOrThrow({ + where: { token: token.token }, + }); + assert.equal(consumed.isExpired, true); + assert.ok(consumed.usedDate); +}); + +void test("signup: a consumed token cannot be reused", async () => { + const token = await prisma.signupToken.findFirstOrThrow({ + where: { email: "new@test.invalid" }, + }); + + const res = await app.inject({ + method: "POST", + url: "/api/auth/signup", + payload: { + username: "newbie2", + email: "new@test.invalid", + name: "New Person", + password: "longenough1", + token: token.token, + }, + }); + assert.equal(res.statusCode, 403); + assert.ok(res.json().data.token); +}); + +void test("signup: an expired token is rejected and marked expired", async () => { + const role = await prisma.role.findUniqueOrThrow({ + where: { roleName: "instructor" }, + }); + const created = await prisma.signupToken.create({ + data: { + token: crypto.randomUUID(), + email: "expired@test.invalid", + shiftNr: 1, + roleId: role.id, + createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000), + }, + }); + + const res = await app.inject({ + method: "POST", + url: "/api/auth/signup", + payload: { + username: "expiredsignup", + email: "expired@test.invalid", + name: "Expired Person", + password: "longenough1", + token: created.token, + }, + }); + assert.equal(res.statusCode, 403); + assert.ok(res.json().data.token); + + const after = await prisma.signupToken.findUniqueOrThrow({ + where: { token: created.token }, + }); + assert.equal(after.isExpired, true); +}); + +void test("signup: a weak password is rejected before the token is consumed", async () => { + const role = await prisma.role.findUniqueOrThrow({ + where: { roleName: "instructor" }, + }); + const created = await prisma.signupToken.create({ + data: { + token: crypto.randomUUID(), + email: "weak@test.invalid", + shiftNr: 1, + roleId: role.id, + }, + }); + + const res = await app.inject({ + method: "POST", + url: "/api/auth/signup", + payload: { + username: "weakling", + email: "weak@test.invalid", + name: "Weak Person", + password: "1234567", + token: created.token, + }, + }); + assert.equal(res.statusCode, 422); + assert.ok(res.json().data.password); + + const untouched = await prisma.signupToken.findUniqueOrThrow({ + where: { token: created.token }, + }); + assert.equal(untouched.isExpired, false); +}); + +void test("signup: a duplicate username is a conflict", async () => { + const role = await prisma.role.findUniqueOrThrow({ + where: { roleName: "instructor" }, + }); + const created = await prisma.signupToken.create({ + data: { + token: crypto.randomUUID(), + email: "dupe@test.invalid", + shiftNr: 1, + roleId: role.id, + }, + }); + + const res = await app.inject({ + method: "POST", + url: "/api/auth/signup", + payload: { + username: "boss1", + email: "dupe@test.invalid", + name: "Dupe Person", + password: "longenough1", + token: created.token, + }, + }); + assert.equal(res.statusCode, 409); + assert.ok(res.json().data.conflict); +}); + +void test("invite: a helper without EDIT_SHIFT_MEMBERS is forbidden", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/users/invites", + headers: { cookie: helperCookie }, + payload: { + email: "nope@test.invalid", + name: "Nope Person", + shiftNr: 1, + role: "helper", + }, + }); + assert.equal(res.statusCode, 403); +}); diff --git a/test/integration/validation.test.ts b/test/integration/validation.test.ts new file mode 100644 index 0000000..85467df --- /dev/null +++ b/test/integration/validation.test.ts @@ -0,0 +1,102 @@ +import { before, after, test } from "node:test"; +import assert from "node:assert/strict"; +import type { FastifyInstance } from "fastify"; + +import { build } from "../helpers/build"; +import { resetDb } from "../helpers/db"; +import { + createShiftInfo, + createUser, + createChildWithRegistration, + loginAs, +} from "../helpers/fixtures"; + +interface FailResponse { + status: string; + data: Record; +} + +let app: FastifyInstance; +let cookie: string; +let regId = 0; + +before(async () => { + await resetDb(); + await createShiftInfo(1); + await createUser({ + username: "boss", + roles: [{ shiftNr: 1, roleName: "boss" }], + }); + const { registration } = await createChildWithRegistration({ + name: "Validation Camper", + shiftNr: 1, + }); + regId = registration.id; + app = await build(); + cookie = await loginAs(app, "boss"); +}); + +after(async () => { + await app.close(); +}); + +void test("negative pricePaid is a 400 keyed on the field", async () => { + const res = await app.inject({ + method: "PATCH", + url: `/api/registrations/${regId}`, + headers: { cookie }, + payload: { pricePaid: -5 }, + }); + assert.equal(res.statusCode, 400); + const body = res.json(); + assert.equal(body.status, "fail"); + assert.ok("pricePaid" in body.data); +}); + +void test("negative priceToPay is a 400", async () => { + const res = await app.inject({ + method: "PATCH", + url: `/api/registrations/${regId}`, + headers: { cookie }, + payload: { priceToPay: -1 }, + }); + assert.equal(res.statusCode, 400); + assert.equal(res.json().status, "fail"); +}); + +void test("an out-of-range tent score is a 400 on both bounds", async () => { + const low = await app.inject({ + method: "POST", + url: "/api/shifts/1/tents/1", + headers: { cookie }, + payload: { score: -1 }, + }); + assert.equal(low.statusCode, 400); + + const high = await app.inject({ + method: "POST", + url: "/api/shifts/1/tents/1", + headers: { cookie }, + payload: { score: 9999 }, + }); + assert.equal(high.statusCode, 400); +}); + +void test("an unknown field is rejected (additionalProperties: false)", async () => { + const res = await app.inject({ + method: "PATCH", + url: `/api/registrations/${regId}`, + headers: { cookie }, + payload: { unknownField: 1 }, + }); + assert.equal(res.statusCode, 400); + assert.equal(res.json().status, "fail"); +}); + +void test("an invalid app platform enum is a 400", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/app/version?platform=windows", + }); + assert.equal(res.statusCode, 400); +}); From 5cbf98c9975befb8d1e51360316b72c0c114d81d Mon Sep 17 00:00:00 2001 From: Taka <71265675+takakv@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:36:32 +0300 Subject: [PATCH 07/10] Add CI workflow --- .github/workflows/ci.yml | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..959f616 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + ci: + runs-on: ubuntu-latest + services: + mariadb: + image: mariadb:11.4 + env: + MARIADB_DATABASE: ml_test + MARIADB_USER: ml_test + MARIADB_PASSWORD: ml_test + MARIADB_ROOT_PASSWORD: root + ports: + - 3307:3306 + options: >- + --health-cmd "healthcheck.sh --connect --innodb_initialized" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + env: + DATABASE_URL: mysql://ml_test:ml_test@127.0.0.1:3307/ml_test + steps: + - uses: actions/checkout@v4 + - run: corepack enable + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: yarn + - run: yarn install --immutable + - run: yarn gen:email + - run: yarn prisma generate + - run: yarn lint + - run: yarn build + - run: yarn typecheck + - run: yarn prisma db push + - run: yarn test From 652d19b3874e29112631d4dbf1bce98ef2f1cf7a Mon Sep 17 00:00:00 2001 From: Taka <71265675+takakv@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:43:59 +0300 Subject: [PATCH 08/10] Add test-suite plan and implementation report --- docs/test-suite-implementation.md | 194 +++++++++ docs/test-suite-plan.md | 687 ++++++++++++++++++++++++++++++ 2 files changed, 881 insertions(+) create mode 100644 docs/test-suite-implementation.md create mode 100644 docs/test-suite-plan.md diff --git a/docs/test-suite-implementation.md b/docs/test-suite-implementation.md new file mode 100644 index 0000000..0d2ed6a --- /dev/null +++ b/docs/test-suite-implementation.md @@ -0,0 +1,194 @@ +# Test Suite — Implementation Report + +Status: implemented, all phases committed on branch `test-suite`. +Audience: a reviewer (Fable) checking this implementation against +`docs/test-suite-plan.md`. Every deviation from the plan is called out +explicitly under **Deviations** — those are the highest-value things to review. + +## 1. What was built + +The plan (`test-suite-plan.md`) was followed phase by phase, one commit each. + +| Commit | Phase | Contents | +|-----------|-------|----------| +| `0b26562` | 0 | Prerequisite production changes (§3.1–3.6) | +| `e48e940` | 1 | `compose.test.yml`, `package.json` scripts, `test/tsconfig.json` | +| `d437263` | 2 | `test/helpers/{test-env,build,db,fixtures}.ts` | +| `91371e9` | 3 | `test/unit/*.test.ts` | +| `a0c6810` | — | Follow-up fix to `resetDb` (see Deviation D1) | +| `04d5bf0` | 4 | `test/integration/*.test.ts` | +| `dd203bb` | 5 | `.github/workflows/ci.yml` | + +Result: `yarn test:full` → **196 pass, 0 fail**, ~10 s test wall-clock. +`yarn lint`, `yarn build`, `yarn typecheck` all clean. Route snapshot unchanged. + +## 2. Verification performed (§9) + +1. `yarn test:db:up && yarn test:db:push` — succeeds; schema in sync. +2. `yarn test` run **twice consecutively** without re-pushing — 196 pass both + times (proves `resetDb` isolation). +3. `yarn lint`, `yarn build`, `yarn typecheck` — all pass. +4. **`DATABASE_NAME` override safety**: ran the suite with `DATABASE_NAME=ml_dev` + exported in the shell; tests still pass because `test-env.ts` unconditionally + forces `ml_test` (if the override leaked, `resetDb`'s guard would throw). +5. Route snapshot test passes unmodified (`test/routes.snapshot.txt` untouched + since before this work — verify with `git log -- test/routes.snapshot.txt`). +6. Wall-clock ~10 s, far under the ~2 min budget. +7. **Not done**: pushing the branch / opening the PR / confirming CI green — this + is an outward-facing action left for the maintainer to trigger. + +## 3. Deviations from the plan (review these first) + +### D1 — `resetDb` required an interactive transaction (`a0c6810`) + +**The plan's §5.3 sequence did not work as written and was corrected.** + +Plan §5.3 said to run, on the shared `prisma` singleton: +`SET FOREIGN_KEY_CHECKS = 0` → `TRUNCATE` each table → `SET FOREIGN_KEY_CHECKS = 1` +as separate `$executeRawUnsafe` calls. + +At runtime this fails immediately: +``` +Cannot truncate a table referenced in a foreign key constraint +(`ml_test`.`registrations`, CONSTRAINT `registrations_billId_fkey` ...) +``` +`SET FOREIGN_KEY_CHECKS` is a **per-connection session variable**, but the +`@prisma/adapter-mariadb` driver pools connections. The `SET` landed on one +pooled connection and the `TRUNCATE`s on others where checks were still on, so +MariaDB refused to truncate any FK-referenced table — independent of whether any +rows existed. + +Fix (`test/helpers/db.ts`): wrap the whole sequence in an interactive +transaction so every statement runs on one pinned connection. `TRUNCATE` +auto-commits but leaves the session flag intact for the connection's lifetime, +so the disabled FK checks apply to every truncate: + +```ts +await prisma.$transaction(async (tx) => { + await tx.$executeRawUnsafe("SET FOREIGN_KEY_CHECKS = 0"); + for (const table of TABLES) { + await tx.$executeRawUnsafe(`TRUNCATE TABLE \`${table}\``); + } + await tx.$executeRawUnsafe("SET FOREIGN_KEY_CHECKS = 1"); +}); +``` + +Semantics are identical to the plan (truncate all tables with FK checks off, +then reseed). Only the connection-pinning mechanism changed. Landed as its own +`Fix …` commit because Phase 2 was already committed (plan §10 permits a +follow-up fix commit). + +**Reviewer check:** confirm this is the minimal correct fix and that no +behaviour beyond connection-pinning changed. + +### D2 — `--skip-generate` dropped from `prisma db push` (Phases 1 & 5) + +Prisma 7 removed the `--skip-generate` flag. The plan's `test:db:push` script +(§4.2) and the CI `prisma db push` step (§8) both used it. Both were written +**without** the flag. Verified `prisma db push` still works and does not +implicitly regenerate the client. This was already flagged as expected in the +pre-compaction notes; calling it out here for completeness. + +### D3 — explicit `Transporter` annotation in the mailer (§3.2) + +`src/plugins/app/nodemailer.ts`: the plan's snippet was +`const transporter = server.config.NODE_ENV === "test" ? … : …`. The two +`nodemailer.createTransport` calls yield different `Transporter` +instantiations whose union is not assignable to the decorated `Transporter` +type, causing TS2345. Added an explicit `const transporter: Transporter =` +annotation. No runtime change. + +### D4 — signup payloads include `email` (§7.2) + +`SignupSchema` (in `auth.schemas.ts`) requires `email: Type.String()`. The +plan's example signup payloads omitted it (e.g. +`{username, name, password, token}`), which would fail schema validation with a +400 before reaching the service. All signup test payloads include `email` so +they exercise the intended 201/403/409/422 paths. The service ignores +`body.email` (it uses the token's email), so this only satisfies validation. + +### D5 — test typing choices (no behaviour impact) + +- `permissions.test.ts` uses a local `Method = "GET"|"POST"|"PATCH"|"DELETE"` + union and `body?: object` for the matrix-case type. Using fastify's + `HTTPMethods` (which includes verbs light-my-request rejects) or `unknown` + bodies broke `app.inject` overload resolution under `tsc`/eslint. `object` is + assignable to `InjectPayload`; the narrow verb union matches inject's method + type. +- `validation.test.ts`: the PATCH calls are inlined rather than routed through a + `patchReg(body: unknown)` helper, because a helper returning `app.inject(...)` + with a loosely-typed body lost the overload resolution and produced + `no-unsafe-*` eslint errors. Direct `app.inject({...})` calls type cleanly. + +## 4. How the permission matrix is structured (§7.4) + +`test/integration/permissions.test.ts` builds a flat `cases` array via an +`add(method, url, body, expectations, opts?)` helper, then iterates it inside one +`test("permission matrix")` with a `t.test(...)` subtest per cell (node:test +requires subtests for loop-generated cases). Notable mechanics a reviewer should +sanity-check: + +- **Dynamic ids** (`regId`, `recordId`, `gradeId`, `billNr`, `userId`) are + passed as `url` **thunks** (`() => \`/api/bills/${capturedBillNr}\``) evaluated + at inject time, so ids captured earlier in the run are visible. +- **`billNr` capture**: the `POST /api/bills` row carries a `capture` callback + that reads `data.billNr` from the 201 response into a module variable; the + subsequent `GET /api/bills/:billNr` row's thunk reads it. Table order (POST + before GET) guarantees ordering. +- **Ordering constraints deliberately encoded in cell order:** + - `DELETE /api/grades/:gradeId` lists `viewer(403), outsider(403), helper(204)` + — the denied cases must run **before** helper's successful delete, otherwise + the grade is already gone and `deleteGrade` returns `true` (204) for a + missing grade, so viewer/outsider would wrongly get 204. + - The mutating registration PATCH rows target the **reserve** registration, not + the billable one, so the billing rows (which key off `bill-parent@test.invalid`) + stay stable. + - `POST /api/records` mutating rows run after the read rows (per plan). +- **Empty-body assertion**: the `requireRoot` sync row sets `assertEmptyBody`, + and the runner asserts `res.body === ""` only on the 403 responses. +- **Fixture record** belongs to the billable (registered) child with + `isActive: true`, so `forceSyncRecords` sees it in-sync and never tries to + `createMany` a duplicate (which would hit the `record_meta_unique` constraint). + +The five §7.4 "additional single tests" (401 without cookie, unknown-path 404, +`norole` empty-array quirk, PDF content-type/non-empty, `notifSent` side effect) +run as separate top-level tests after the matrix. + +## 5. Files added / changed + +**Production (Phase 0):** `src/config/env.ts`, `src/lib/prisma.ts`, +`src/plugins/app/nodemailer.ts`, `src/lib/guards.ts`, +`src/routes/api/registrations/registrations.schemas.ts`, +`src/routes/api/shifts/shifts.schemas.ts`, `src/lib/permissions.ts`, +`src/routes/api/registrations/registrations.service.ts`, +`prisma/seed-core.ts` (new), `prisma/seed.ts`. + +**Seed behaviour change (§3.5):** `EDIT_SHIFT_MEMBERS` added to the `root` and +`boss` permission lists in `seed-core.ts`. Idempotent upsert — **run +`yarn seed` against existing databases** to apply it. Without it, +`POST /api/users/invites` was 403 for every seeded role. + +**Infra:** `compose.test.yml`, `.github/workflows/ci.yml`, `package.json` +scripts, `test/tsconfig.json` (added `../src` to `include` so the +`FastifyContextConfig.public` augmentation in `routes/api/autohooks.ts` is in +the typecheck graph). + +**Helpers:** `test/helpers/{test-env,build,db,fixtures}.ts`. + +**Unit tests:** `test/unit/{id-code,pricing,age,password,date,guards}.test.ts`. + +**Integration tests:** `test/integration/{auth,signup,password-reset,` +`permissions,registration-visibility,registration-create,validation}.test.ts`. + +## 6. Open questions for the reviewer + +1. **D1** — is pinning `resetDb` to an interactive transaction acceptable, or is + there a preference for a different mechanism (e.g. `DELETE` instead of + `TRUNCATE`, or a dedicated single-connection client)? The plan's literal + sequence cannot work under the pooled adapter. +2. Should the `resetDb` fix have been squashed into the Phase 2 commit + (`d437263`) via rebase rather than landed as a follow-up `Fix …` commit? Kept + as a separate commit to avoid rewriting already-made history. +3. The seed grant of `EDIT_SHIFT_MEMBERS` to root/boss (§3.5) is a real + production behaviour change shipped with the test work — confirm this is the + intended fix and that operators know to re-run `yarn seed`. diff --git a/docs/test-suite-plan.md b/docs/test-suite-plan.md new file mode 100644 index 0000000..850741f --- /dev/null +++ b/docs/test-suite-plan.md @@ -0,0 +1,687 @@ +# Test Suite Implementation Plan + +Status: approved design, ready for implementation. +Audience: an implementing agent (Opus/Sonnet). All architectural decisions are +made here — follow them exactly; do not redesign. When this plan conflicts with +your own judgement, the plan wins. If the plan conflicts with observed reality +(e.g. a file has moved), stop and report rather than improvising. + +## 1. Goals and non-goals + +Goals: + +- Integration tests that exercise the real HTTP surface (`app.inject`) against + a real MariaDB, with the **permission matrix** (role × endpoint → status) as + the centrepiece. +- Field-level filtering tests for `GET /api/registrations` (PII/financial/ + contact visibility per role). +- Auth lifecycle tests (login, logout, password change, signup tokens, + password reset) asserting DB side effects. +- Unit tests for the pure functions (`parseIdCode`, `computePrice`, + `getAgeAtDate`, date utils, password policy, guard hardening). +- GitHub Actions CI running lint, typecheck, build, and the full test suite + against a MariaDB service container. +- A handful of small production fixes that are prerequisites for the tests to + be correct (§3). Nothing else in production code changes. + +Non-goals (explicitly out of scope — do not do these): + +- No new test framework. Use `node:test` + `tsx`, as the repo already does. +- No mocking of Prisma. Tests hit the real test database. +- No coverage tooling, no test parallelism tuning beyond what §4 specifies. +- No refactoring of route files, services, or response shapes beyond §3. The + wire API is frozen; tests **pin current behaviour**, including the quirks + (empty 403 body from `requireRoot`, 200-with-empty-array for unauthorised + registration reads, 304 from `POST /api/records`, 404-masking on + registration patches). +- Do not split feature `index.ts` route files (project constraint). +- Do not touch `trustProxy`, bills/notifications dedup, or anything else from + the broader review that isn't listed in §3. + +## 2. Fixed decisions + +| Topic | Decision | +|---|---| +| Framework | `node:test`, `tsx` loader, `assert/strict` (already in use) | +| DB engine | MariaDB **11.4** (pin image `mariadb:11.4` everywhere) | +| Local DB | Docker Compose file `compose.test.yml`, host port **3307**, tmpfs storage | +| CI DB | GitHub Actions service container, same image, mapped to host port **3307** so config is identical to local | +| DB credentials (test) | user `ml_test`, password `ml_test`, database `ml_test` — hardcoded in the test env helper | +| Schema creation | `prisma db push` (repo has no migrations directory) | +| Test isolation | One shared DB; test **files run sequentially** (`--test-concurrency=1`); each integration file calls `resetDb()` once in `before()`; tests within a file use disjoint fixtures (unique usernames/emails) and run serially (node:test default within a file) | +| DB reset | `TRUNCATE` every app table with `FOREIGN_KEY_CHECKS=0`, then re-seed roles/permissions | +| Safety guard | `resetDb()` throws unless `process.env.DATABASE_NAME === "ml_test"` | +| Env for tests | `test/helpers/test-env.ts` **unconditionally** sets all required env vars via `Object.assign(process.env, …)` before any `#app` import. `.env` values can never leak in (dotenv never overrides pre-set vars) | +| Mail | nodemailer `jsonTransport` when `NODE_ENV === "test"` (no network). Tests assert DB side effects, not email contents | +| Password hashing in fixtures | `bcrypt.hashSync(TEST_PASSWORD, 4)` — cost 4 keeps the suite fast; `bcrypt.compare` doesn't care about cost | +| Node version | 24 (matches dev machine) | +| CI triggers | `push` to `main` + all `pull_request` | +| Directory layout | `test/helpers/`, `test/unit/`, `test/integration/`; existing `smoke.test.ts`, `routes.test.ts` stay at `test/` root unchanged | + +## 3. Phase 0 — prerequisite production changes + +Small, wire-compatible changes. Each is required for the tests to be +implementable or trustworthy. Make them first, in one commit. + +### 3.1 `DATABASE_PORT` support + +The test DB listens on 3307; the MariaDB adapter currently hardcodes the +default port. + +- `src/config/env.ts`: add to `EnvConfig`: `DATABASE_PORT: number;` and to + `envSchema.properties`: `DATABASE_PORT: { type: "number", default: 3306 }`. + Do **not** add it to `required` (the default covers it). +- `src/lib/prisma.ts`: add `port: Number(process.env.DATABASE_PORT ?? 3306),` + to the `PrismaMariaDb` options. + +### 3.2 Hermetic mailer in test env + +`src/plugins/app/nodemailer.ts` — replace the transporter construction: + +```ts +const transporter = + server.config.NODE_ENV === "test" + ? nodemailer.createTransport({ jsonTransport: true }) + : nodemailer.createTransport(mg(config)); +``` + +The `verify()` call is already skipped for `NODE_ENV === "test"`; leave that +as is. + +### 3.3 Guard hardening (`getShiftNr`) + +`src/lib/guards.ts` — replace the unchecked cast: + +```ts +const getShiftNr = (request: FastifyRequest, source: ShiftNrSource): number => { + const value = (request[source] as { shiftNr?: unknown } | undefined)?.shiftNr; + if (typeof value !== "number" || !Number.isInteger(value)) { + throw new Error( + `Guard misconfiguration: request.${source} does not contain an integer shiftNr`, + ); + } + return value; +}; +``` + +Rationale: without this, a mis-wired guard passes `undefined` into Prisma, +which drops the `shiftNr` filter and silently broadens the permission check to +"any shift". The permission-matrix tests are only trustworthy with this fixed. +A throw surfaces as a 500 via the error handler — that is intended (it is a +programmer error, and access stays denied). + +### 3.4 Numeric bounds on write schemas + +Only these three fields (they map to unsigned DB columns; out-of-range values +currently surface as 500s instead of validation failures): + +- `src/routes/api/registrations/registrations.schemas.ts` + (`PatchRegistrationSchema`): `pricePaid: Type.Optional(Type.Integer({ minimum: 0 }))`, + `priceToPay: Type.Optional(Type.Integer({ minimum: 0 }))`. +- `src/routes/api/shifts/shifts.schemas.ts` (`AddGradeSchema`): + `score: Type.Integer({ minimum: 0, maximum: 255 })` (255 = DB `UnsignedTinyInt` + bound; do not tighten further — the wire API is frozen and larger business + bounds are not established). + +Do not add bounds anywhere else. + +### 3.5 Extract the seed into an importable function + +Tests must seed roles/permissions programmatically after truncation. + +- Create `prisma/seed-core.ts`: move `BASE_SHIFT_PERMISSIONS` and + `ROLE_PERMISSIONS` from `prisma/seed.ts` into it verbatim, and export: + + ```ts + export const seedRolesAndPermissions = async (client: PrismaClient): Promise + ``` + + containing the current `main()` body (role upsert loop, permission-id cache, + rolePermission upserts), parameterised on `client` instead of the imported + singleton. Import `PrismaClient` as a type from `#app/generated/prisma/client`. +- Rewrite `prisma/seed.ts` as a thin wrapper: import the singleton + `prisma` and `seedRolesAndPermissions`, call it, keep the existing + `catch`/`finally` structure. +- While moving `ROLE_PERMISSIONS`, add `Permissions.EDIT_SHIFT_MEMBERS` to + **both** the `root` and `boss` permission lists (approved seed fix: without + it, `POST /api/users/invites` is 403 for every seeded role). The seed is + upsert-based and idempotent, so this is additive on existing databases. + Mention this seed change in the final report. + +### 3.6 Single source for registration view flags + +`src/lib/permissions.ts`: + +```ts +export const deriveRegistrationViewFlags = ( + perms: ReadonlySet, +): RegistrationViewFlags => ({ + pii: + perms.has(Permissions.VIEW_REGISTRATION_FULL) || + perms.has(Permissions.VIEW_REGISTRATION_PERSONAL_INFO), + financial: + perms.has(Permissions.VIEW_REGISTRATION_FULL) || + perms.has(Permissions.VIEW_REGISTRATION_PRICE), + contact: + perms.has(Permissions.VIEW_REGISTRATION_FULL) || + perms.has(Permissions.VIEW_REGISTRATION_CONTACT), +}); +``` + +`getRegistrationViewFlags` becomes `fetchUserShiftPermissions(...)` + +`deriveRegistrationViewFlags(...)`. In +`src/routes/api/registrations/registrations.service.ts`, replace the three +inline `canViewPII/canViewFinancial/canViewContact` computations with one +`deriveRegistrationViewFlags(shiftViewPermissions)` call (keep the +`size === 0 → empty result` early return). No behaviour change; the +field-filtering tests then cover the single implementation. + +## 4. Phase 1 — test infrastructure + +### 4.1 `compose.test.yml` (repo root) + +```yaml +services: + mariadb: + image: mariadb:11.4 + environment: + MARIADB_DATABASE: ml_test + MARIADB_USER: ml_test + MARIADB_PASSWORD: ml_test + MARIADB_ROOT_PASSWORD: root + ports: + - "3307:3306" + tmpfs: + - /var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 5s + timeout: 5s + retries: 12 +``` + +### 4.2 `package.json` script changes + +```jsonc +"test": "NODE_ENV=test node --import tsx --test --test-concurrency=1 --test-force-exit \"test/**/*.test.ts\"", +"test:db:up": "docker compose -f compose.test.yml up -d --wait", +"test:db:down": "docker compose -f compose.test.yml down -v", +"test:db:push": "DATABASE_URL=mysql://ml_test:ml_test@127.0.0.1:3307/ml_test prisma db push --skip-generate", +"test:full": "yarn test:db:up && yarn test:db:push && yarn gen:email && yarn test", +"typecheck": "tsc -p test/tsconfig.json && tsc -p prisma/tsconfig.json" +``` + +Notes: `--test-concurrency=1` is load-bearing (shared DB; node runs test files +in parallel processes by default). The glob stays quoted so node, not the +shell, expands it. Keep `--test-force-exit` (the Prisma session store holds an +interval timer). + +## 5. Phase 2 — test helpers + +All helpers live in `test/helpers/`. Import order matters: **`test-env` must +be the first import** of any module that transitively imports `#app/*`. + +### 5.1 `test/helpers/test-env.ts` + +Side-effect module, no exports: + +```ts +Object.assign(process.env, { + NODE_ENV: "test", + PORT: "0", + APP_URL: "http://app.test.invalid", + COOKIE_SECRET: "test-cookie-secret-0123456789-0123456789", + MAILGUN_API_KEY: "test-key", + EMAIL_SERV: "mail.test.invalid", + DATABASE_HOST: "127.0.0.1", + DATABASE_PORT: "3307", + DATABASE_USER: "ml_test", + DATABASE_PASSWORD: "ml_test", + DATABASE_NAME: "ml_test", +}); +delete process.env.COOKIE_DOMAIN; +delete process.env.DATABASE_URL; +``` + +Assignments are unconditional so a developer's `.env`/shell can never point +tests at a real database (dotenv never overrides already-set vars, in both +`lib/prisma.ts` and `@fastify/env`). + +### 5.2 `test/helpers/build.ts` (edit existing) + +Add `import "./test-env";` as the **first** import line. Everything else stays. + +### 5.3 `test/helpers/db.ts` + +```ts +import "./test-env"; +import prisma from "#app/lib/prisma"; +import { seedRolesAndPermissions } from "../../prisma/seed-core"; +``` + +Export `resetDb(): Promise`: + +1. Throw if `process.env.DATABASE_NAME !== "ml_test"`. +2. `SET FOREIGN_KEY_CHECKS = 0` (via `$executeRawUnsafe`). +3. `TRUNCATE TABLE \`\`` for each of (exact `@@map` names): + `bills, certificates, children, documents, event_info, general_info, + permissions, records, registrations, reset_tokens, role_permissions, roles, + sessions, shift_staff, shifts, signup_tokens, teams, tent_scores, + user_roles, users`. +4. `SET FOREIGN_KEY_CHECKS = 1`. +5. `await seedRolesAndPermissions(prisma)`. + +Also export `prisma` re-exported from `#app/lib/prisma` so test files import +the client from one place. + +### 5.4 `test/helpers/fixtures.ts` + +`import "./test-env";` first. Exports (signatures are contracts — keep them): + +```ts +export const TEST_PASSWORD = "test-password-123"; + +// ShiftInfo row. Defaults: bossName "Boss", bossEmail `boss${shiftNr}@test.invalid`, +// bossPhone "5550000", length 12, startDate = 90 days from now at UTC midnight, +// id = shiftNr. Overridable via `overrides`. +export const createShiftInfo = (shiftNr: number, overrides?: Partial) => Promise; + +// User + optional per-shift roles. Password is always TEST_PASSWORD hashed at cost 4. +// roles: e.g. [{ shiftNr: 1, roleName: "boss" }] -> looks up Role by roleName, +// creates UserRoles rows. superRoot: sets User.role = "root". +// email defaults to `${username}@test.invalid`, name to username, currentShift to +// the first role's shiftNr or 1. +export const createUser = (opts: { + username: string; + roles?: { shiftNr: number; roleName: RoleName }[]; + superRoot?: boolean; + email?: string | null; + currentShift?: number; +}) => Promise; + +// Child + Registration. Maintains a module-level regOrder counter. +// Defaults: sex "M", birthday 2014-05-05 UTC, tsSize "M", road/city/county "x", +// country "Eesti", contactName "Parent", contactNumber "5551234", +// contactEmail `parent${counter}@test.invalid`, isRegistered false, isOld true, +// priceToPay 250, regId = crypto.randomUUID(). All overridable. +export const createChildWithRegistration = (opts: { + name: string; + shiftNr: number; + overrides?: Partial; +}) => Promise<{ child: Child; registration: Registration }>; + +// Logs in via POST /api/auth/login and returns the cookie header value +// ("sessionId=..."), ready for inject({ headers: { cookie } }). +// Asserts the login itself returned 200. +export const loginAs = (app: FastifyInstance, username: string) => Promise; +``` + +`loginAs` extracts the cookie from `res.cookies` (find `name === "sessionId"`) +and returns `` `${name}=${value}` ``. + +## 6. Phase 3 — unit tests (`test/unit/`) + +No DB, no app build. One file per subject; plain `test()` blocks. + +- **`id-code.test.ts`** — `parseIdCode`: valid male code (starts `5`) → + `{sex:"M", dob}` with correct ISO date; valid female (`6`); length ≠ 11 → + error; non-digits → error; first digit `3` (adult) → error; embedded + invalid date (e.g. month 13 or Feb 30) → error; leap-day code (e.g. + `50402290...`) → valid. +- **`pricing.test.ts`** — `computePrice`: shift 1 new = 250, shift 1 old = + 240, shift 2 new = 360, shift 2 old = 340 (derive expected values from the + `SHIFT_PRICES`/`SENIORITY_DISCOUNTS` constants, don't hardcode independently); + shift 0 and shift 5 → -1. +- **`age.test.ts`** — `getAgeAtDate`: birthday later in target year (age not + yet incremented), exact birthday, day before birthday, same month earlier + day. +- **`password.test.ts`** — `validatePasswordPolicy`: 7 chars → message, + 8 chars → null. +- **`date.test.ts`** — `startOfUTCDay`, `addUTCDays` across month/year + boundary, `subUTCMonths` day-overflow (Mar 31 − 1 month → Mar 2/3 behaviour + as implemented — assert what the function actually does per its doc + comment), `formatUTCDate` with an explicit locale. +- **`guards.test.ts`** — after §3.3: calling the handler returned by + `requireShiftPermission(Permissions.VIEW_SHIFT_BASIC, "params")` with a fake + request `{ params: {}, session: { user: { userId: 1 } } }` and a stub reply + must **reject** (assert `assert.rejects`). No DB call happens because the + throw precedes the Prisma query. + +## 7. Phase 4 — integration tests (`test/integration/`) + +Common skeleton for every file: + +```ts +import { before, after, test } from "node:test"; +import assert from "node:assert/strict"; +import type { FastifyInstance } from "fastify"; +import { build } from "../helpers/build"; +import { resetDb, prisma } from "../helpers/db"; +import { /* fixtures */ } from "../helpers/fixtures"; + +let app: FastifyInstance; +before(async () => { + await resetDb(); + // fixtures for this file + app = await build(); +}); +after(async () => { await app.close(); }); +``` + +Build the app **after** fixtures exist where boot-time state matters (the +`regorder` plugin reads max `regOrder` at startup). + +### 7.1 `auth.test.ts` + +Fixtures: shift 1 (`createShiftInfo(1)`); user `alice` with boss role on +shift 1. + +- Login OK: `POST /api/auth/login` `{username:"alice", password:TEST_PASSWORD}` + → 200, body `status:"success"`, `data.userId` matches, `data.isRoot === false`, + `data.managedShifts` includes 1; a `sessionId` cookie is set. +- Username normalisation: login as `" ALICE "` → 200. +- Wrong password → 401, `status:"fail"`, `data.message` present. +- Unknown user → 401 (same shape — no enumeration). +- `GET /api/auth/me` with cookie → 200 + same UserInfo shape; without cookie + → 401. +- Logout: `POST /api/auth/logout` → 204; the old cookie on `/me` → 401. +- Password change: login twice (two cookies A and B). With A, + `POST /api/auth/password` wrong `currentPassword` → 401; new password 7 + chars → 422; valid change → 204. Then: cookie A still works on `/me` + (session kept), cookie B → 401 (invalidated), login with the new password + → 200, with the old → 401. + +### 7.2 `signup.test.ts` + +Fixtures: shift 1; boss user `boss1` (boss role, shift 1). The seeded boss +role has `EDIT_SHIFT_MEMBERS` (§3.5), so no extra grant is needed. + +- Invite happy path: as boss, `POST /api/users/invites` + `{email:"new@test.invalid", name:"New Person", shiftNr:1, role:"instructor"}` + → 204; assert a `signup_tokens` row exists for that email with a `roleId`, + and a `shift_staff` row (`role: "full"`) exists. +- Invalid role value → 422 with `data.role`. +- Signup happy path: `POST /api/auth/signup` with the stored token, + `{username:"newbie", name:"New Person", password:"longenough1", token}` → + 201; login as `newbie` works; `user_roles` row exists (instructor, shift 1); + token row has `isExpired: true` and a `usedDate`. +- Token reuse → 403 (`data.token`). +- Expired token: create a `signupToken` row directly with + `createdAt: new Date(Date.now() - 25 * 3600 * 1000)` (set at **create** + time; `createdAt` is settable, `updatedAt` is not) → signup → 403 and the + row is now `isExpired: true`. +- Weak password → 422 (`data.password`). +- Duplicate username (`boss1`) with a fresh token → 409 (`data.conflict`). +- Invite as a helper-role user → 403 (the seed grants `EDIT_SHIFT_MEMBERS` + only to root and boss). + +### 7.3 `password-reset.test.ts` + +Fixtures: shift 1; user `carol` (email `carol@test.invalid`). + +- `POST /api/account/password-reset` unknown email → 202 and **no** + `reset_tokens` row. +- Known email → 202 and a `reset_tokens` row for carol's userId (email send is + jsonTransport, succeeds silently). +- `PUT /api/account/password` bad token → 403. +- Valid token + weak password → 422; token still usable afterwards. +- Valid token + good password → 204; login with new password works; all of + carol's `reset_tokens` rows are gone; carol's pre-existing session cookie + → 401. +- Expired token (row created with backdated `createdAt`) → 403 and the row is + deleted. + +### 7.4 `permissions.test.ts` — the matrix + +Fixtures (in `before`): + +- Shifts: `createShiftInfo(1)`, `createShiftInfo(2)`. +- Users (all on shift 1 unless noted): + `superroot` (`superRoot: true`, plus boss role on shift 1 so `/me` works), + `boss` (boss), `instructor` (instructor), `helper` (helper), + `viewer` (reg-viewer-basic), `outsider` (boss role but on **shift 2**), + `norole` (no roles at all). +- Data in shift 1: two children with registrations (one + `isRegistered: true, visible: true`, contactEmail `bill-parent@test.invalid`, + `priceToPay: 340`; one reserve), one `record` (via direct + `prisma.record.create`, `year: current UTC year`, `isActive: true`), one + `team` (current year), one `tentScore` (current year, tent 1). +- Helper also gets a role in **shift 99** (no ShiftInfo row exists — allowed, + there's no FK) so "guard passes but resource missing" paths are reachable. +- Log in every user once in `before`; keep a `cookies: Record` + map. + +Implementation pattern (node:test requires subtests for loop-generated cases): + +```ts +void test("permission matrix", async (t) => { + for (const c of cases) { + await t.test(`${c.method} ${c.url} as ${c.as} -> ${c.expect}`, async () => { + const res = await app.inject({ + method: c.method, url: c.url, payload: c.body, + headers: { cookie: cookies[c.as] }, + }); + assert.equal(res.statusCode, c.expect); + }); + } +}); +``` + +Cases (`as` → expected status). Where a POST/PATCH body is needed it is given +once; reuse it for every user in that row. IDs come from the fixtures. + +| Method & URL | body | superroot | boss | instructor | helper | viewer | outsider | norole | +|---|---|---|---|---|---|---|---|---| +| GET `/api/shifts` | — | 200 | 200 | 200 | 200 | 200 | 200 | 200 | +| GET `/api/shifts/1/users` | — | | 200 | 403 | 403 | 403 | 403 | 403 | +| GET `/api/shifts/1/billing` | — | | 200 | 403 | 403 | 403 | 403 | | +| GET `/api/shifts/1/records` | — | | 200 | 200 | 200 | 403 | 403 | 403 | +| GET `/api/shifts/1/emails` | — | | 200 | 200 | 403 | 403 | 403 | | +| GET `/api/shifts/1/staff` | — | | 200 | 200 | 200 | 403 | 403 | | +| GET `/api/shifts/1/pdf` | — | | 200 | 403 | 403 | 403 | 403 | | +| GET `/api/shifts/1/tents` | — | | 200 | 200 | 200 | 403 | 403 | | +| GET `/api/shifts/1/tents/1` | — | | 200 | | 200 | 403 | 403 | | +| POST `/api/shifts/1/tents/2` | `{score: 5}` | | 201 | 201 | 201 | 403 | 403 | 403 | +| GET `/api/teams?shiftNr=1` | — | | 200 | | 200 | 403 | 403 | | +| POST `/api/teams` | `{shiftNr: 1, name: "Uus"}` | | | | 201 | 403 | 403 | | +| GET `/api/registrations?shiftNr=1` | — | | 200 | 200 | 200 | 200 | 200 | 200 | +| PATCH `/api/registrations/:regId` | `{isRegistered: true}` | | 204 | 404 | 404 | 404 | 404 | 404 | +| PATCH `/api/registrations/:regId` | `{pricePaid: 10}` | | 204 | 404 | | | | | +| POST `/api/registrations/sync` | — | 204 | 403 | | 403 | | | 403 | +| POST `/api/bills` | `{email: "bill-parent@test.invalid"}` | | 201 | 403 | 403 | 403 | | 403 | +| GET `/api/bills/:billNr` (from the 201 above) | — | | 200 | | 403 | | | | +| GET `/api/bills/999999` | — | | 404 | | | | | | +| POST `/api/notifications/bills` | `{email: "bill-parent@test.invalid"}` | | 204 | 403 | 403 | | | | +| POST `/api/notifications/bills` | `{email: "nobody@test.invalid"}` | | 404 | | | | | | +| POST `/api/records` | `{shiftNr: 1, forceSync: true}` | | 204 | 204 | 204 | 403 | 403 | 403 | +| POST `/api/records` | `{shiftNr: 1, forceSync: false}` | | 304 | | | | | | +| POST `/api/records` | `{shiftNr: 99, forceSync: true}` | | 403 | | 404 (helper has role in 99; shift missing) | | | | +| PATCH `/api/records/:recordId` | `{tentNr: 3}` | | 204 | 204 | 204 | 403 | 403 | 403 | +| PATCH `/api/records/999999` | `{tentNr: 3}` | | 404 | | | | | | +| DELETE `/api/grades/:gradeId` | — | | | | 204 | 403 | 403 | | +| DELETE `/api/grades/999999` | — | | 204 | | | | | | +| PATCH `/api/users/:ownId` | `{currentShift: 1}` | | 204 (self) | | | | | | +| PATCH `/api/users/:bossId` as instructor | `{currentShift: 1}` | | | 403 | | | | | +| PATCH `/api/users/:ownId` | `{currentShift: 2}` (not a member) | | 403 | | | | | | +| GET `/api/records?childId=:childId` | — | | 200 | 200 | 200 | | 403 | 403 | +| POST `/api/users/invites` | `{email:"x@test.invalid", name:"X", shiftNr:1, role:"helper"}` | | 204 | 403 | 403 | | 403 | | + +Blank cells = don't test that combination. The empty-body 403 from +`requireRoot` (sync row) should additionally assert `res.body === ""`. +Ordering constraints: run the two mutating PATCH-registration rows and +`POST /api/records` rows **after** the read-only rows (they change +`isRegistered` and records); the bills GET row needs the bills POST row first +— keep the cases array in the table's order and it works out, since +`GET /api/registrations` content assertions live in a different file (§7.5). + +Additional single tests in the same file: + +- Any protected URL without a cookie → 401 with + `{status:"fail", data:{message}}`. +- Unknown path `/api/nonsense` (with cookie) → 404 with `data.path`. +- `GET /api/registrations?shiftNr=1` as `norole` → 200 **and** + `data.registrations` is `[]` (pins the frozen "empty array instead of 403" + behaviour). +- `GET /api/shifts/1/pdf` as boss: assert `content-type` starts with + `application/pdf` and the payload is non-empty. +- After the `POST /api/notifications/bills` 204: the registered registration + has `notifSent: true` in the DB. + +### 7.5 `registration-visibility.test.ts` + +Fixtures: shift 1; users `boss`, `instructor`, `helper`, `viewer` (as in +§7.4); one registration with **all** optional fields populated (addendum, +backupTel, billId null, etc.). + +For `GET /api/registrations?shiftNr=1`, assert on the first element of +`data.registrations`: + +- as `boss`: `birthday`, `road`, `county`, `country`, `addendum`, + `pricePaid`, `priceToPay`, `notifSent`, `billId`, `contactName`, + `contactNumber`, `contactEmail`, `backupTel` all **present**; + `child.currentAge` is an integer. +- as `instructor` (contact only): `contactName`/`contactEmail`/ + `contactNumber` present; `birthday`, `road`, `pricePaid`, `priceToPay`, + `billId` **absent** — assert with `assert.ok(!("birthday" in reg))`, not + `undefined` equality. +- as `helper` and as `viewer` (basic only): all permission-gated fields + absent; `id`, `childId`, `child.name`, `child.sex`, `child.currentAge`, + `shiftNr`, `isRegistered`, `regOrder`, `isOld`, `tsSize` present. + +### 7.6 `registration-create.test.ts` + +Public endpoint — no login. Fixtures: shifts 1 and 2. Build app after +fixtures. + +- Happy path with idCode: POST one entry with a **valid** 11-digit child code + (construct one starting with `5`, digits 2–7 encoding a real date, any + 4-digit tail — the checksum is deliberately not validated) → 201, + `data.registrationId` is a UUID; DB: `children` row created with sex/ + birthYear derived from the code; `registrations` row has `priceToPay === + computePrice(shiftNr, isOld)`. +- Happy path without idCode but with `sex` + `dob` → 201. +- Invalid idCode (bad date inside) → 400, `status:"fail"`, fail data keyed + `"[0].idCode"`, and **no** child/registration rows created (transaction + rolled back). +- Missing sex+dob and no idCode → 400 with `"[0].sex"` / `"[0].dob"` keys. +- Duplicate: register the same child (same idCode) for the same shift twice + (two sequential requests) → both 201 (no leak), but the second + `registrations` row has `visible: false`. +- Five entries in the array → 400 (schema `maxItems: 4`). +- `regOrder`: two sequential requests get strictly increasing `regOrder` + values in the DB. + +### 7.7 `validation.test.ts` + +Login as `boss` (fixtures as in §7.4, one registration, one shift). + +- `PATCH /api/registrations/:regId` `{pricePaid: -5}` → **400** with + `status:"fail"` and fail-data key `pricePaid` (exercises §3.4 + the AJV + error handler mapping). +- Same with `{priceToPay: -1}` → 400. +- `POST /api/shifts/1/tents/1` `{score: -1}` → 400; `{score: 9999}` → 400. +- `PATCH /api/registrations/:regId` `{unknownField: 1}` → 400 + (`additionalProperties: false`). +- `GET /api/app/version?platform=windows` → 400 (public route, enum + validation). + +### 7.8 Existing files + +`test/smoke.test.ts` and `test/routes.test.ts` stay byte-identical. They pick +up the test DB automatically through the edited build helper. The route +snapshot must not change — none of the Phase 0 edits add/remove routes; if the +snapshot test fails, a Phase 0 edit went wrong (do **not** regenerate the +snapshot to make it pass). + +## 8. Phase 5 — GitHub Actions + +Create `.github/workflows/ci.yml` exactly: + +```yaml +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + ci: + runs-on: ubuntu-latest + services: + mariadb: + image: mariadb:11.4 + env: + MARIADB_DATABASE: ml_test + MARIADB_USER: ml_test + MARIADB_PASSWORD: ml_test + MARIADB_ROOT_PASSWORD: root + ports: + - 3307:3306 + options: >- + --health-cmd "healthcheck.sh --connect --innodb_initialized" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + env: + DATABASE_URL: mysql://ml_test:ml_test@127.0.0.1:3307/ml_test + steps: + - uses: actions/checkout@v4 + - run: corepack enable + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: yarn + - run: yarn install --immutable + - run: yarn gen:email + - run: yarn prisma generate + - run: yarn lint + - run: yarn build + - run: yarn typecheck + - run: yarn prisma db push --skip-generate + - run: yarn test +``` + +Notes baked into this design: + +- `src/generated/**` is gitignored, so CI must run `gen:email` **and** + `prisma generate` before anything that type-checks `src` (lint is + type-aware; build is `tsc`). +- `corepack enable` must precede `setup-node` with `cache: yarn` (Yarn 4). +- `prisma.config.ts` reads `DATABASE_URL` at config load, hence the job-level + env — it is needed even for `prisma generate`. +- The test-env helper hardcodes host `127.0.0.1:3307`, matching the service + port mapping; no app-level env vars are needed in the workflow. + +## 9. Phase 6 — verification & acceptance + +1. `yarn test:db:up && yarn test:db:push` succeeds from a clean checkout + (plus `yarn gen:email` and `yarn prisma generate` if not yet run). +2. `yarn test` passes; run it **twice in a row** without re-pushing the schema + (proves `resetDb` isolation). +3. `yarn lint`, `yarn build`, `yarn typecheck` all pass. +4. Temporarily set `DATABASE_NAME=ml_dev` in the shell and confirm the suite + still targets `ml_test` (test-env overrides) — then unset. +5. The route snapshot test passes unmodified. +6. Full suite wall-clock under ~2 minutes locally. +7. Push a branch, open a PR, confirm the workflow is green. + +Definition of done: all of the above, plus a short summary in the PR/commit +description listing (a) the Phase 0 production changes, (b) the frozen quirks +the tests now pin, and (c) the §3.5 seed change (`EDIT_SHIFT_MEMBERS` now +granted to root and boss — run `yarn seed` against existing databases to +apply it). + +## 10. Commit plan + +One commit per phase, in order: + +1. `Add test-suite prerequisites` (Phase 0, §3.1–3.6) +2. `Add test DB compose file and scripts` (Phase 1) +3. `Add test helpers` (Phase 2) +4. `Add unit tests` (Phase 3) +5. `Add integration tests` (Phase 4) +6. `Add CI workflow` (Phase 5) + +Run the full verification (§9) before the final commit; fix forward within +the relevant phase's commit via amend or a follow-up `Fix …` commit. From e99f2136c34a90d3549f8eca05ca3eba99c28c05 Mon Sep 17 00:00:00 2001 From: Taka <71265675+takakv@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:20:16 +0300 Subject: [PATCH 09/10] Allow combined childId+shiftNr records query --- src/routes/api/records/records.schemas.ts | 24 ++-- src/routes/api/records/records.service.ts | 47 ++++--- test/integration/records-query.test.ts | 146 ++++++++++++++++++++++ 3 files changed, 190 insertions(+), 27 deletions(-) create mode 100644 test/integration/records-query.test.ts diff --git a/src/routes/api/records/records.schemas.ts b/src/routes/api/records/records.schemas.ts index 0a1432d..7685c25 100644 --- a/src/routes/api/records/records.schemas.ts +++ b/src/routes/api/records/records.schemas.ts @@ -1,20 +1,18 @@ import { Static, Type } from "@sinclair/typebox"; -const ShiftRecordsFilterSchema = Type.Object( - { shiftNr: Type.Integer() }, - { additionalProperties: false }, +export const RecordsFetchSchema = Type.Object( + { + shiftNr: Type.Optional(Type.Integer()), + childId: Type.Optional(Type.Integer()), + }, + { + additionalProperties: false, + description: + "Filter by shift (current year), by child (all years), or by both. At least one is required.", + anyOf: [{ required: ["shiftNr"] }, { required: ["childId"] }], + }, ); -const CamperRecordsFilterSchema = Type.Object( - { childId: Type.Integer() }, - { additionalProperties: false }, -); - -export const RecordsFetchSchema = Type.Union([ - ShiftRecordsFilterSchema, - CamperRecordsFilterSchema, -]); - export type RecordsFetchQuery = Static; export const ForceSyncSchema = Type.Object({ diff --git a/src/routes/api/records/records.service.ts b/src/routes/api/records/records.service.ts index f1ab9a6..f2197c5 100644 --- a/src/routes/api/records/records.service.ts +++ b/src/routes/api/records/records.service.ts @@ -148,32 +148,45 @@ const fetchShiftRecords = async ( }; // Returns null when the user is not authorised to view the camper's records. +// With `shiftNr` set, results are limited to that shift and authorisation is +// checked against that shift alone; otherwise a permission in any shift the +// child was registered in grants access to the full history. const fetchCamperRecords = async ( childId: number, userId: number, log: FastifyBaseLogger, + shiftNr?: number, ): Promise => { - const registrations = await prisma.registration.findMany({ - where: { childId }, - select: { shiftNr: true }, - }); + let isAuthorised: boolean; + if (shiftNr !== undefined) { + isAuthorised = await canViewShiftBasic(userId, shiftNr); + } else { + const registrations = await prisma.registration.findMany({ + where: { childId }, + select: { shiftNr: true }, + }); - const isAuthorised = await userHasShiftPermissionInAnyOf( - userId, - registrations.map((registration) => registration.shiftNr), - Permissions.VIEW_SHIFT_BASIC, - ); + isAuthorised = await userHasShiftPermissionInAnyOf( + userId, + registrations.map((registration) => registration.shiftNr), + Permissions.VIEW_SHIFT_BASIC, + ); + } if (!isAuthorised) { log.warn( - { userId, childId }, + { userId, childId, shiftNr }, "User not authorised to view historic records", ); return null; } const records = await prisma.record.findMany({ - where: { childId, isActive: true }, + where: { + childId, + isActive: true, + ...(shiftNr !== undefined ? { shiftNr } : {}), + }, include: recordRelations, orderBy: [{ year: "desc" }, { shiftNr: "asc" }], }); @@ -186,10 +199,16 @@ export const fetchRecordsForQuery = async ( userId: number, log: FastifyBaseLogger, ): Promise => { - if ("childId" in query) { - return fetchCamperRecords(query.childId, userId, log); + const { childId, shiftNr } = query; + if (childId !== undefined) { + return fetchCamperRecords(childId, userId, log, shiftNr); + } + if (shiftNr !== undefined) { + return fetchShiftRecords(shiftNr, userId, log); } - return fetchShiftRecords(query.shiftNr, userId, log); + // Unreachable: the schema requires at least one filter. Denying is the safe + // fallback if that invariant is ever broken. + return null; }; export type PatchRecordResult = diff --git a/test/integration/records-query.test.ts b/test/integration/records-query.test.ts new file mode 100644 index 0000000..c87d1b8 --- /dev/null +++ b/test/integration/records-query.test.ts @@ -0,0 +1,146 @@ +import { before, after, test } from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import type { FastifyInstance } from "fastify"; + +import { build } from "../helpers/build"; +import { resetDb, prisma } from "../helpers/db"; +import { + createShiftInfo, + createUser, + createChildWithRegistration, + loginAs, +} from "../helpers/fixtures"; + +const YEAR = new Date().getUTCFullYear(); + +interface RecordsResponse { + status: string; + data: { records: { shiftNr: number; year: number }[] }; +} + +let app: FastifyInstance; +let childId = 0; + +const cookies: Record = {}; + +const fetchRecords = (qs: string, as: string) => + app.inject({ + method: "GET", + url: `/api/records${qs}`, + headers: { cookie: cookies[as] }, + }); + +before(async () => { + await resetDb(); + await createShiftInfo(1); + await createShiftInfo(2); + + await createUser({ + username: "boss1", + roles: [{ shiftNr: 1, roleName: "boss" }], + }); + await createUser({ + username: "boss2", + roles: [{ shiftNr: 2, roleName: "boss" }], + }); + await createUser({ username: "norole" }); + + // One child registered in both shifts, with a current-year record in shift 1 + // and a previous-year record in shift 2. + const { child, registration } = await createChildWithRegistration({ + name: "Cross Shift Camper", + shiftNr: 1, + overrides: { isRegistered: true }, + }); + childId = child.id; + + await prisma.registration.create({ + data: { + childId, + shiftNr: 2, + regId: randomUUID(), + regOrder: registration.regOrder + 1000, + birthday: registration.birthday, + tsSize: "M", + road: "x", + city: "x", + county: "x", + contactName: "Parent", + contactNumber: "5551234", + contactEmail: registration.contactEmail, + }, + }); + + await prisma.record.create({ + data: { childId, shiftNr: 1, year: YEAR, ageAtCamp: 11, isActive: true }, + }); + await prisma.record.create({ + data: { + childId, + shiftNr: 2, + year: YEAR - 1, + ageAtCamp: 10, + isActive: true, + }, + }); + + app = await build(); + for (const username of ["boss1", "boss2", "norole"]) { + cookies[username] = await loginAs(app, username); + } +}); + +after(async () => { + await app.close(); +}); + +void test("childId alone returns the child's records across shifts and years", async () => { + const res = await fetchRecords(`?childId=${childId}`, "boss1"); + assert.equal(res.statusCode, 200); + const { records } = res.json().data; + assert.equal(records.length, 2); + assert.deepEqual( + records.map((r) => r.shiftNr).sort(), + [1, 2], + ); +}); + +void test("childId + shiftNr narrows the records to that shift", async () => { + const res = await fetchRecords(`?childId=${childId}&shiftNr=1`, "boss1"); + assert.equal(res.statusCode, 200); + const { records } = res.json().data; + assert.equal(records.length, 1); + assert.equal(records[0].shiftNr, 1); +}); + +void test("childId + shiftNr authorises against that shift alone", async () => { + // boss2 may not view shift 1, even though the child is also in shift 2. + const denied = await fetchRecords(`?childId=${childId}&shiftNr=1`, "boss2"); + assert.equal(denied.statusCode, 403); + + const allowed = await fetchRecords(`?childId=${childId}&shiftNr=2`, "boss2"); + assert.equal(allowed.statusCode, 200); + const { records } = allowed.json().data; + assert.equal(records.length, 1); + assert.equal(records[0].shiftNr, 2); +}); + +void test("shiftNr alone still returns the current-year shift records", async () => { + const res = await fetchRecords("?shiftNr=1", "boss1"); + assert.equal(res.statusCode, 200); + const { records } = res.json().data; + assert.equal(records.length, 1); + assert.equal(records[0].year, YEAR); +}); + +void test("no filter at all is a 400", async () => { + const res = await fetchRecords("", "boss1"); + assert.equal(res.statusCode, 400); + assert.equal(res.json<{ status: string }>().status, "fail"); +}); + +void test("a user with no roles is denied the combined query", async () => { + const res = await fetchRecords(`?childId=${childId}&shiftNr=1`, "norole"); + assert.equal(res.statusCode, 403); +}); From 7a1ed05581d25b70afe744b3b9af6d78ffaedfdc Mon Sep 17 00:00:00 2001 From: Taka <71265675+takakv@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:20:54 +0300 Subject: [PATCH 10/10] Sunset refactor plans --- docs/test-suite-implementation.md | 194 --------- docs/test-suite-plan.md | 687 ------------------------------ 2 files changed, 881 deletions(-) delete mode 100644 docs/test-suite-implementation.md delete mode 100644 docs/test-suite-plan.md diff --git a/docs/test-suite-implementation.md b/docs/test-suite-implementation.md deleted file mode 100644 index 0d2ed6a..0000000 --- a/docs/test-suite-implementation.md +++ /dev/null @@ -1,194 +0,0 @@ -# Test Suite — Implementation Report - -Status: implemented, all phases committed on branch `test-suite`. -Audience: a reviewer (Fable) checking this implementation against -`docs/test-suite-plan.md`. Every deviation from the plan is called out -explicitly under **Deviations** — those are the highest-value things to review. - -## 1. What was built - -The plan (`test-suite-plan.md`) was followed phase by phase, one commit each. - -| Commit | Phase | Contents | -|-----------|-------|----------| -| `0b26562` | 0 | Prerequisite production changes (§3.1–3.6) | -| `e48e940` | 1 | `compose.test.yml`, `package.json` scripts, `test/tsconfig.json` | -| `d437263` | 2 | `test/helpers/{test-env,build,db,fixtures}.ts` | -| `91371e9` | 3 | `test/unit/*.test.ts` | -| `a0c6810` | — | Follow-up fix to `resetDb` (see Deviation D1) | -| `04d5bf0` | 4 | `test/integration/*.test.ts` | -| `dd203bb` | 5 | `.github/workflows/ci.yml` | - -Result: `yarn test:full` → **196 pass, 0 fail**, ~10 s test wall-clock. -`yarn lint`, `yarn build`, `yarn typecheck` all clean. Route snapshot unchanged. - -## 2. Verification performed (§9) - -1. `yarn test:db:up && yarn test:db:push` — succeeds; schema in sync. -2. `yarn test` run **twice consecutively** without re-pushing — 196 pass both - times (proves `resetDb` isolation). -3. `yarn lint`, `yarn build`, `yarn typecheck` — all pass. -4. **`DATABASE_NAME` override safety**: ran the suite with `DATABASE_NAME=ml_dev` - exported in the shell; tests still pass because `test-env.ts` unconditionally - forces `ml_test` (if the override leaked, `resetDb`'s guard would throw). -5. Route snapshot test passes unmodified (`test/routes.snapshot.txt` untouched - since before this work — verify with `git log -- test/routes.snapshot.txt`). -6. Wall-clock ~10 s, far under the ~2 min budget. -7. **Not done**: pushing the branch / opening the PR / confirming CI green — this - is an outward-facing action left for the maintainer to trigger. - -## 3. Deviations from the plan (review these first) - -### D1 — `resetDb` required an interactive transaction (`a0c6810`) - -**The plan's §5.3 sequence did not work as written and was corrected.** - -Plan §5.3 said to run, on the shared `prisma` singleton: -`SET FOREIGN_KEY_CHECKS = 0` → `TRUNCATE` each table → `SET FOREIGN_KEY_CHECKS = 1` -as separate `$executeRawUnsafe` calls. - -At runtime this fails immediately: -``` -Cannot truncate a table referenced in a foreign key constraint -(`ml_test`.`registrations`, CONSTRAINT `registrations_billId_fkey` ...) -``` -`SET FOREIGN_KEY_CHECKS` is a **per-connection session variable**, but the -`@prisma/adapter-mariadb` driver pools connections. The `SET` landed on one -pooled connection and the `TRUNCATE`s on others where checks were still on, so -MariaDB refused to truncate any FK-referenced table — independent of whether any -rows existed. - -Fix (`test/helpers/db.ts`): wrap the whole sequence in an interactive -transaction so every statement runs on one pinned connection. `TRUNCATE` -auto-commits but leaves the session flag intact for the connection's lifetime, -so the disabled FK checks apply to every truncate: - -```ts -await prisma.$transaction(async (tx) => { - await tx.$executeRawUnsafe("SET FOREIGN_KEY_CHECKS = 0"); - for (const table of TABLES) { - await tx.$executeRawUnsafe(`TRUNCATE TABLE \`${table}\``); - } - await tx.$executeRawUnsafe("SET FOREIGN_KEY_CHECKS = 1"); -}); -``` - -Semantics are identical to the plan (truncate all tables with FK checks off, -then reseed). Only the connection-pinning mechanism changed. Landed as its own -`Fix …` commit because Phase 2 was already committed (plan §10 permits a -follow-up fix commit). - -**Reviewer check:** confirm this is the minimal correct fix and that no -behaviour beyond connection-pinning changed. - -### D2 — `--skip-generate` dropped from `prisma db push` (Phases 1 & 5) - -Prisma 7 removed the `--skip-generate` flag. The plan's `test:db:push` script -(§4.2) and the CI `prisma db push` step (§8) both used it. Both were written -**without** the flag. Verified `prisma db push` still works and does not -implicitly regenerate the client. This was already flagged as expected in the -pre-compaction notes; calling it out here for completeness. - -### D3 — explicit `Transporter` annotation in the mailer (§3.2) - -`src/plugins/app/nodemailer.ts`: the plan's snippet was -`const transporter = server.config.NODE_ENV === "test" ? … : …`. The two -`nodemailer.createTransport` calls yield different `Transporter` -instantiations whose union is not assignable to the decorated `Transporter` -type, causing TS2345. Added an explicit `const transporter: Transporter =` -annotation. No runtime change. - -### D4 — signup payloads include `email` (§7.2) - -`SignupSchema` (in `auth.schemas.ts`) requires `email: Type.String()`. The -plan's example signup payloads omitted it (e.g. -`{username, name, password, token}`), which would fail schema validation with a -400 before reaching the service. All signup test payloads include `email` so -they exercise the intended 201/403/409/422 paths. The service ignores -`body.email` (it uses the token's email), so this only satisfies validation. - -### D5 — test typing choices (no behaviour impact) - -- `permissions.test.ts` uses a local `Method = "GET"|"POST"|"PATCH"|"DELETE"` - union and `body?: object` for the matrix-case type. Using fastify's - `HTTPMethods` (which includes verbs light-my-request rejects) or `unknown` - bodies broke `app.inject` overload resolution under `tsc`/eslint. `object` is - assignable to `InjectPayload`; the narrow verb union matches inject's method - type. -- `validation.test.ts`: the PATCH calls are inlined rather than routed through a - `patchReg(body: unknown)` helper, because a helper returning `app.inject(...)` - with a loosely-typed body lost the overload resolution and produced - `no-unsafe-*` eslint errors. Direct `app.inject({...})` calls type cleanly. - -## 4. How the permission matrix is structured (§7.4) - -`test/integration/permissions.test.ts` builds a flat `cases` array via an -`add(method, url, body, expectations, opts?)` helper, then iterates it inside one -`test("permission matrix")` with a `t.test(...)` subtest per cell (node:test -requires subtests for loop-generated cases). Notable mechanics a reviewer should -sanity-check: - -- **Dynamic ids** (`regId`, `recordId`, `gradeId`, `billNr`, `userId`) are - passed as `url` **thunks** (`() => \`/api/bills/${capturedBillNr}\``) evaluated - at inject time, so ids captured earlier in the run are visible. -- **`billNr` capture**: the `POST /api/bills` row carries a `capture` callback - that reads `data.billNr` from the 201 response into a module variable; the - subsequent `GET /api/bills/:billNr` row's thunk reads it. Table order (POST - before GET) guarantees ordering. -- **Ordering constraints deliberately encoded in cell order:** - - `DELETE /api/grades/:gradeId` lists `viewer(403), outsider(403), helper(204)` - — the denied cases must run **before** helper's successful delete, otherwise - the grade is already gone and `deleteGrade` returns `true` (204) for a - missing grade, so viewer/outsider would wrongly get 204. - - The mutating registration PATCH rows target the **reserve** registration, not - the billable one, so the billing rows (which key off `bill-parent@test.invalid`) - stay stable. - - `POST /api/records` mutating rows run after the read rows (per plan). -- **Empty-body assertion**: the `requireRoot` sync row sets `assertEmptyBody`, - and the runner asserts `res.body === ""` only on the 403 responses. -- **Fixture record** belongs to the billable (registered) child with - `isActive: true`, so `forceSyncRecords` sees it in-sync and never tries to - `createMany` a duplicate (which would hit the `record_meta_unique` constraint). - -The five §7.4 "additional single tests" (401 without cookie, unknown-path 404, -`norole` empty-array quirk, PDF content-type/non-empty, `notifSent` side effect) -run as separate top-level tests after the matrix. - -## 5. Files added / changed - -**Production (Phase 0):** `src/config/env.ts`, `src/lib/prisma.ts`, -`src/plugins/app/nodemailer.ts`, `src/lib/guards.ts`, -`src/routes/api/registrations/registrations.schemas.ts`, -`src/routes/api/shifts/shifts.schemas.ts`, `src/lib/permissions.ts`, -`src/routes/api/registrations/registrations.service.ts`, -`prisma/seed-core.ts` (new), `prisma/seed.ts`. - -**Seed behaviour change (§3.5):** `EDIT_SHIFT_MEMBERS` added to the `root` and -`boss` permission lists in `seed-core.ts`. Idempotent upsert — **run -`yarn seed` against existing databases** to apply it. Without it, -`POST /api/users/invites` was 403 for every seeded role. - -**Infra:** `compose.test.yml`, `.github/workflows/ci.yml`, `package.json` -scripts, `test/tsconfig.json` (added `../src` to `include` so the -`FastifyContextConfig.public` augmentation in `routes/api/autohooks.ts` is in -the typecheck graph). - -**Helpers:** `test/helpers/{test-env,build,db,fixtures}.ts`. - -**Unit tests:** `test/unit/{id-code,pricing,age,password,date,guards}.test.ts`. - -**Integration tests:** `test/integration/{auth,signup,password-reset,` -`permissions,registration-visibility,registration-create,validation}.test.ts`. - -## 6. Open questions for the reviewer - -1. **D1** — is pinning `resetDb` to an interactive transaction acceptable, or is - there a preference for a different mechanism (e.g. `DELETE` instead of - `TRUNCATE`, or a dedicated single-connection client)? The plan's literal - sequence cannot work under the pooled adapter. -2. Should the `resetDb` fix have been squashed into the Phase 2 commit - (`d437263`) via rebase rather than landed as a follow-up `Fix …` commit? Kept - as a separate commit to avoid rewriting already-made history. -3. The seed grant of `EDIT_SHIFT_MEMBERS` to root/boss (§3.5) is a real - production behaviour change shipped with the test work — confirm this is the - intended fix and that operators know to re-run `yarn seed`. diff --git a/docs/test-suite-plan.md b/docs/test-suite-plan.md deleted file mode 100644 index 850741f..0000000 --- a/docs/test-suite-plan.md +++ /dev/null @@ -1,687 +0,0 @@ -# Test Suite Implementation Plan - -Status: approved design, ready for implementation. -Audience: an implementing agent (Opus/Sonnet). All architectural decisions are -made here — follow them exactly; do not redesign. When this plan conflicts with -your own judgement, the plan wins. If the plan conflicts with observed reality -(e.g. a file has moved), stop and report rather than improvising. - -## 1. Goals and non-goals - -Goals: - -- Integration tests that exercise the real HTTP surface (`app.inject`) against - a real MariaDB, with the **permission matrix** (role × endpoint → status) as - the centrepiece. -- Field-level filtering tests for `GET /api/registrations` (PII/financial/ - contact visibility per role). -- Auth lifecycle tests (login, logout, password change, signup tokens, - password reset) asserting DB side effects. -- Unit tests for the pure functions (`parseIdCode`, `computePrice`, - `getAgeAtDate`, date utils, password policy, guard hardening). -- GitHub Actions CI running lint, typecheck, build, and the full test suite - against a MariaDB service container. -- A handful of small production fixes that are prerequisites for the tests to - be correct (§3). Nothing else in production code changes. - -Non-goals (explicitly out of scope — do not do these): - -- No new test framework. Use `node:test` + `tsx`, as the repo already does. -- No mocking of Prisma. Tests hit the real test database. -- No coverage tooling, no test parallelism tuning beyond what §4 specifies. -- No refactoring of route files, services, or response shapes beyond §3. The - wire API is frozen; tests **pin current behaviour**, including the quirks - (empty 403 body from `requireRoot`, 200-with-empty-array for unauthorised - registration reads, 304 from `POST /api/records`, 404-masking on - registration patches). -- Do not split feature `index.ts` route files (project constraint). -- Do not touch `trustProxy`, bills/notifications dedup, or anything else from - the broader review that isn't listed in §3. - -## 2. Fixed decisions - -| Topic | Decision | -|---|---| -| Framework | `node:test`, `tsx` loader, `assert/strict` (already in use) | -| DB engine | MariaDB **11.4** (pin image `mariadb:11.4` everywhere) | -| Local DB | Docker Compose file `compose.test.yml`, host port **3307**, tmpfs storage | -| CI DB | GitHub Actions service container, same image, mapped to host port **3307** so config is identical to local | -| DB credentials (test) | user `ml_test`, password `ml_test`, database `ml_test` — hardcoded in the test env helper | -| Schema creation | `prisma db push` (repo has no migrations directory) | -| Test isolation | One shared DB; test **files run sequentially** (`--test-concurrency=1`); each integration file calls `resetDb()` once in `before()`; tests within a file use disjoint fixtures (unique usernames/emails) and run serially (node:test default within a file) | -| DB reset | `TRUNCATE` every app table with `FOREIGN_KEY_CHECKS=0`, then re-seed roles/permissions | -| Safety guard | `resetDb()` throws unless `process.env.DATABASE_NAME === "ml_test"` | -| Env for tests | `test/helpers/test-env.ts` **unconditionally** sets all required env vars via `Object.assign(process.env, …)` before any `#app` import. `.env` values can never leak in (dotenv never overrides pre-set vars) | -| Mail | nodemailer `jsonTransport` when `NODE_ENV === "test"` (no network). Tests assert DB side effects, not email contents | -| Password hashing in fixtures | `bcrypt.hashSync(TEST_PASSWORD, 4)` — cost 4 keeps the suite fast; `bcrypt.compare` doesn't care about cost | -| Node version | 24 (matches dev machine) | -| CI triggers | `push` to `main` + all `pull_request` | -| Directory layout | `test/helpers/`, `test/unit/`, `test/integration/`; existing `smoke.test.ts`, `routes.test.ts` stay at `test/` root unchanged | - -## 3. Phase 0 — prerequisite production changes - -Small, wire-compatible changes. Each is required for the tests to be -implementable or trustworthy. Make them first, in one commit. - -### 3.1 `DATABASE_PORT` support - -The test DB listens on 3307; the MariaDB adapter currently hardcodes the -default port. - -- `src/config/env.ts`: add to `EnvConfig`: `DATABASE_PORT: number;` and to - `envSchema.properties`: `DATABASE_PORT: { type: "number", default: 3306 }`. - Do **not** add it to `required` (the default covers it). -- `src/lib/prisma.ts`: add `port: Number(process.env.DATABASE_PORT ?? 3306),` - to the `PrismaMariaDb` options. - -### 3.2 Hermetic mailer in test env - -`src/plugins/app/nodemailer.ts` — replace the transporter construction: - -```ts -const transporter = - server.config.NODE_ENV === "test" - ? nodemailer.createTransport({ jsonTransport: true }) - : nodemailer.createTransport(mg(config)); -``` - -The `verify()` call is already skipped for `NODE_ENV === "test"`; leave that -as is. - -### 3.3 Guard hardening (`getShiftNr`) - -`src/lib/guards.ts` — replace the unchecked cast: - -```ts -const getShiftNr = (request: FastifyRequest, source: ShiftNrSource): number => { - const value = (request[source] as { shiftNr?: unknown } | undefined)?.shiftNr; - if (typeof value !== "number" || !Number.isInteger(value)) { - throw new Error( - `Guard misconfiguration: request.${source} does not contain an integer shiftNr`, - ); - } - return value; -}; -``` - -Rationale: without this, a mis-wired guard passes `undefined` into Prisma, -which drops the `shiftNr` filter and silently broadens the permission check to -"any shift". The permission-matrix tests are only trustworthy with this fixed. -A throw surfaces as a 500 via the error handler — that is intended (it is a -programmer error, and access stays denied). - -### 3.4 Numeric bounds on write schemas - -Only these three fields (they map to unsigned DB columns; out-of-range values -currently surface as 500s instead of validation failures): - -- `src/routes/api/registrations/registrations.schemas.ts` - (`PatchRegistrationSchema`): `pricePaid: Type.Optional(Type.Integer({ minimum: 0 }))`, - `priceToPay: Type.Optional(Type.Integer({ minimum: 0 }))`. -- `src/routes/api/shifts/shifts.schemas.ts` (`AddGradeSchema`): - `score: Type.Integer({ minimum: 0, maximum: 255 })` (255 = DB `UnsignedTinyInt` - bound; do not tighten further — the wire API is frozen and larger business - bounds are not established). - -Do not add bounds anywhere else. - -### 3.5 Extract the seed into an importable function - -Tests must seed roles/permissions programmatically after truncation. - -- Create `prisma/seed-core.ts`: move `BASE_SHIFT_PERMISSIONS` and - `ROLE_PERMISSIONS` from `prisma/seed.ts` into it verbatim, and export: - - ```ts - export const seedRolesAndPermissions = async (client: PrismaClient): Promise - ``` - - containing the current `main()` body (role upsert loop, permission-id cache, - rolePermission upserts), parameterised on `client` instead of the imported - singleton. Import `PrismaClient` as a type from `#app/generated/prisma/client`. -- Rewrite `prisma/seed.ts` as a thin wrapper: import the singleton - `prisma` and `seedRolesAndPermissions`, call it, keep the existing - `catch`/`finally` structure. -- While moving `ROLE_PERMISSIONS`, add `Permissions.EDIT_SHIFT_MEMBERS` to - **both** the `root` and `boss` permission lists (approved seed fix: without - it, `POST /api/users/invites` is 403 for every seeded role). The seed is - upsert-based and idempotent, so this is additive on existing databases. - Mention this seed change in the final report. - -### 3.6 Single source for registration view flags - -`src/lib/permissions.ts`: - -```ts -export const deriveRegistrationViewFlags = ( - perms: ReadonlySet, -): RegistrationViewFlags => ({ - pii: - perms.has(Permissions.VIEW_REGISTRATION_FULL) || - perms.has(Permissions.VIEW_REGISTRATION_PERSONAL_INFO), - financial: - perms.has(Permissions.VIEW_REGISTRATION_FULL) || - perms.has(Permissions.VIEW_REGISTRATION_PRICE), - contact: - perms.has(Permissions.VIEW_REGISTRATION_FULL) || - perms.has(Permissions.VIEW_REGISTRATION_CONTACT), -}); -``` - -`getRegistrationViewFlags` becomes `fetchUserShiftPermissions(...)` + -`deriveRegistrationViewFlags(...)`. In -`src/routes/api/registrations/registrations.service.ts`, replace the three -inline `canViewPII/canViewFinancial/canViewContact` computations with one -`deriveRegistrationViewFlags(shiftViewPermissions)` call (keep the -`size === 0 → empty result` early return). No behaviour change; the -field-filtering tests then cover the single implementation. - -## 4. Phase 1 — test infrastructure - -### 4.1 `compose.test.yml` (repo root) - -```yaml -services: - mariadb: - image: mariadb:11.4 - environment: - MARIADB_DATABASE: ml_test - MARIADB_USER: ml_test - MARIADB_PASSWORD: ml_test - MARIADB_ROOT_PASSWORD: root - ports: - - "3307:3306" - tmpfs: - - /var/lib/mysql - healthcheck: - test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] - interval: 5s - timeout: 5s - retries: 12 -``` - -### 4.2 `package.json` script changes - -```jsonc -"test": "NODE_ENV=test node --import tsx --test --test-concurrency=1 --test-force-exit \"test/**/*.test.ts\"", -"test:db:up": "docker compose -f compose.test.yml up -d --wait", -"test:db:down": "docker compose -f compose.test.yml down -v", -"test:db:push": "DATABASE_URL=mysql://ml_test:ml_test@127.0.0.1:3307/ml_test prisma db push --skip-generate", -"test:full": "yarn test:db:up && yarn test:db:push && yarn gen:email && yarn test", -"typecheck": "tsc -p test/tsconfig.json && tsc -p prisma/tsconfig.json" -``` - -Notes: `--test-concurrency=1` is load-bearing (shared DB; node runs test files -in parallel processes by default). The glob stays quoted so node, not the -shell, expands it. Keep `--test-force-exit` (the Prisma session store holds an -interval timer). - -## 5. Phase 2 — test helpers - -All helpers live in `test/helpers/`. Import order matters: **`test-env` must -be the first import** of any module that transitively imports `#app/*`. - -### 5.1 `test/helpers/test-env.ts` - -Side-effect module, no exports: - -```ts -Object.assign(process.env, { - NODE_ENV: "test", - PORT: "0", - APP_URL: "http://app.test.invalid", - COOKIE_SECRET: "test-cookie-secret-0123456789-0123456789", - MAILGUN_API_KEY: "test-key", - EMAIL_SERV: "mail.test.invalid", - DATABASE_HOST: "127.0.0.1", - DATABASE_PORT: "3307", - DATABASE_USER: "ml_test", - DATABASE_PASSWORD: "ml_test", - DATABASE_NAME: "ml_test", -}); -delete process.env.COOKIE_DOMAIN; -delete process.env.DATABASE_URL; -``` - -Assignments are unconditional so a developer's `.env`/shell can never point -tests at a real database (dotenv never overrides already-set vars, in both -`lib/prisma.ts` and `@fastify/env`). - -### 5.2 `test/helpers/build.ts` (edit existing) - -Add `import "./test-env";` as the **first** import line. Everything else stays. - -### 5.3 `test/helpers/db.ts` - -```ts -import "./test-env"; -import prisma from "#app/lib/prisma"; -import { seedRolesAndPermissions } from "../../prisma/seed-core"; -``` - -Export `resetDb(): Promise`: - -1. Throw if `process.env.DATABASE_NAME !== "ml_test"`. -2. `SET FOREIGN_KEY_CHECKS = 0` (via `$executeRawUnsafe`). -3. `TRUNCATE TABLE \`\`` for each of (exact `@@map` names): - `bills, certificates, children, documents, event_info, general_info, - permissions, records, registrations, reset_tokens, role_permissions, roles, - sessions, shift_staff, shifts, signup_tokens, teams, tent_scores, - user_roles, users`. -4. `SET FOREIGN_KEY_CHECKS = 1`. -5. `await seedRolesAndPermissions(prisma)`. - -Also export `prisma` re-exported from `#app/lib/prisma` so test files import -the client from one place. - -### 5.4 `test/helpers/fixtures.ts` - -`import "./test-env";` first. Exports (signatures are contracts — keep them): - -```ts -export const TEST_PASSWORD = "test-password-123"; - -// ShiftInfo row. Defaults: bossName "Boss", bossEmail `boss${shiftNr}@test.invalid`, -// bossPhone "5550000", length 12, startDate = 90 days from now at UTC midnight, -// id = shiftNr. Overridable via `overrides`. -export const createShiftInfo = (shiftNr: number, overrides?: Partial) => Promise; - -// User + optional per-shift roles. Password is always TEST_PASSWORD hashed at cost 4. -// roles: e.g. [{ shiftNr: 1, roleName: "boss" }] -> looks up Role by roleName, -// creates UserRoles rows. superRoot: sets User.role = "root". -// email defaults to `${username}@test.invalid`, name to username, currentShift to -// the first role's shiftNr or 1. -export const createUser = (opts: { - username: string; - roles?: { shiftNr: number; roleName: RoleName }[]; - superRoot?: boolean; - email?: string | null; - currentShift?: number; -}) => Promise; - -// Child + Registration. Maintains a module-level regOrder counter. -// Defaults: sex "M", birthday 2014-05-05 UTC, tsSize "M", road/city/county "x", -// country "Eesti", contactName "Parent", contactNumber "5551234", -// contactEmail `parent${counter}@test.invalid`, isRegistered false, isOld true, -// priceToPay 250, regId = crypto.randomUUID(). All overridable. -export const createChildWithRegistration = (opts: { - name: string; - shiftNr: number; - overrides?: Partial; -}) => Promise<{ child: Child; registration: Registration }>; - -// Logs in via POST /api/auth/login and returns the cookie header value -// ("sessionId=..."), ready for inject({ headers: { cookie } }). -// Asserts the login itself returned 200. -export const loginAs = (app: FastifyInstance, username: string) => Promise; -``` - -`loginAs` extracts the cookie from `res.cookies` (find `name === "sessionId"`) -and returns `` `${name}=${value}` ``. - -## 6. Phase 3 — unit tests (`test/unit/`) - -No DB, no app build. One file per subject; plain `test()` blocks. - -- **`id-code.test.ts`** — `parseIdCode`: valid male code (starts `5`) → - `{sex:"M", dob}` with correct ISO date; valid female (`6`); length ≠ 11 → - error; non-digits → error; first digit `3` (adult) → error; embedded - invalid date (e.g. month 13 or Feb 30) → error; leap-day code (e.g. - `50402290...`) → valid. -- **`pricing.test.ts`** — `computePrice`: shift 1 new = 250, shift 1 old = - 240, shift 2 new = 360, shift 2 old = 340 (derive expected values from the - `SHIFT_PRICES`/`SENIORITY_DISCOUNTS` constants, don't hardcode independently); - shift 0 and shift 5 → -1. -- **`age.test.ts`** — `getAgeAtDate`: birthday later in target year (age not - yet incremented), exact birthday, day before birthday, same month earlier - day. -- **`password.test.ts`** — `validatePasswordPolicy`: 7 chars → message, - 8 chars → null. -- **`date.test.ts`** — `startOfUTCDay`, `addUTCDays` across month/year - boundary, `subUTCMonths` day-overflow (Mar 31 − 1 month → Mar 2/3 behaviour - as implemented — assert what the function actually does per its doc - comment), `formatUTCDate` with an explicit locale. -- **`guards.test.ts`** — after §3.3: calling the handler returned by - `requireShiftPermission(Permissions.VIEW_SHIFT_BASIC, "params")` with a fake - request `{ params: {}, session: { user: { userId: 1 } } }` and a stub reply - must **reject** (assert `assert.rejects`). No DB call happens because the - throw precedes the Prisma query. - -## 7. Phase 4 — integration tests (`test/integration/`) - -Common skeleton for every file: - -```ts -import { before, after, test } from "node:test"; -import assert from "node:assert/strict"; -import type { FastifyInstance } from "fastify"; -import { build } from "../helpers/build"; -import { resetDb, prisma } from "../helpers/db"; -import { /* fixtures */ } from "../helpers/fixtures"; - -let app: FastifyInstance; -before(async () => { - await resetDb(); - // fixtures for this file - app = await build(); -}); -after(async () => { await app.close(); }); -``` - -Build the app **after** fixtures exist where boot-time state matters (the -`regorder` plugin reads max `regOrder` at startup). - -### 7.1 `auth.test.ts` - -Fixtures: shift 1 (`createShiftInfo(1)`); user `alice` with boss role on -shift 1. - -- Login OK: `POST /api/auth/login` `{username:"alice", password:TEST_PASSWORD}` - → 200, body `status:"success"`, `data.userId` matches, `data.isRoot === false`, - `data.managedShifts` includes 1; a `sessionId` cookie is set. -- Username normalisation: login as `" ALICE "` → 200. -- Wrong password → 401, `status:"fail"`, `data.message` present. -- Unknown user → 401 (same shape — no enumeration). -- `GET /api/auth/me` with cookie → 200 + same UserInfo shape; without cookie - → 401. -- Logout: `POST /api/auth/logout` → 204; the old cookie on `/me` → 401. -- Password change: login twice (two cookies A and B). With A, - `POST /api/auth/password` wrong `currentPassword` → 401; new password 7 - chars → 422; valid change → 204. Then: cookie A still works on `/me` - (session kept), cookie B → 401 (invalidated), login with the new password - → 200, with the old → 401. - -### 7.2 `signup.test.ts` - -Fixtures: shift 1; boss user `boss1` (boss role, shift 1). The seeded boss -role has `EDIT_SHIFT_MEMBERS` (§3.5), so no extra grant is needed. - -- Invite happy path: as boss, `POST /api/users/invites` - `{email:"new@test.invalid", name:"New Person", shiftNr:1, role:"instructor"}` - → 204; assert a `signup_tokens` row exists for that email with a `roleId`, - and a `shift_staff` row (`role: "full"`) exists. -- Invalid role value → 422 with `data.role`. -- Signup happy path: `POST /api/auth/signup` with the stored token, - `{username:"newbie", name:"New Person", password:"longenough1", token}` → - 201; login as `newbie` works; `user_roles` row exists (instructor, shift 1); - token row has `isExpired: true` and a `usedDate`. -- Token reuse → 403 (`data.token`). -- Expired token: create a `signupToken` row directly with - `createdAt: new Date(Date.now() - 25 * 3600 * 1000)` (set at **create** - time; `createdAt` is settable, `updatedAt` is not) → signup → 403 and the - row is now `isExpired: true`. -- Weak password → 422 (`data.password`). -- Duplicate username (`boss1`) with a fresh token → 409 (`data.conflict`). -- Invite as a helper-role user → 403 (the seed grants `EDIT_SHIFT_MEMBERS` - only to root and boss). - -### 7.3 `password-reset.test.ts` - -Fixtures: shift 1; user `carol` (email `carol@test.invalid`). - -- `POST /api/account/password-reset` unknown email → 202 and **no** - `reset_tokens` row. -- Known email → 202 and a `reset_tokens` row for carol's userId (email send is - jsonTransport, succeeds silently). -- `PUT /api/account/password` bad token → 403. -- Valid token + weak password → 422; token still usable afterwards. -- Valid token + good password → 204; login with new password works; all of - carol's `reset_tokens` rows are gone; carol's pre-existing session cookie - → 401. -- Expired token (row created with backdated `createdAt`) → 403 and the row is - deleted. - -### 7.4 `permissions.test.ts` — the matrix - -Fixtures (in `before`): - -- Shifts: `createShiftInfo(1)`, `createShiftInfo(2)`. -- Users (all on shift 1 unless noted): - `superroot` (`superRoot: true`, plus boss role on shift 1 so `/me` works), - `boss` (boss), `instructor` (instructor), `helper` (helper), - `viewer` (reg-viewer-basic), `outsider` (boss role but on **shift 2**), - `norole` (no roles at all). -- Data in shift 1: two children with registrations (one - `isRegistered: true, visible: true`, contactEmail `bill-parent@test.invalid`, - `priceToPay: 340`; one reserve), one `record` (via direct - `prisma.record.create`, `year: current UTC year`, `isActive: true`), one - `team` (current year), one `tentScore` (current year, tent 1). -- Helper also gets a role in **shift 99** (no ShiftInfo row exists — allowed, - there's no FK) so "guard passes but resource missing" paths are reachable. -- Log in every user once in `before`; keep a `cookies: Record` - map. - -Implementation pattern (node:test requires subtests for loop-generated cases): - -```ts -void test("permission matrix", async (t) => { - for (const c of cases) { - await t.test(`${c.method} ${c.url} as ${c.as} -> ${c.expect}`, async () => { - const res = await app.inject({ - method: c.method, url: c.url, payload: c.body, - headers: { cookie: cookies[c.as] }, - }); - assert.equal(res.statusCode, c.expect); - }); - } -}); -``` - -Cases (`as` → expected status). Where a POST/PATCH body is needed it is given -once; reuse it for every user in that row. IDs come from the fixtures. - -| Method & URL | body | superroot | boss | instructor | helper | viewer | outsider | norole | -|---|---|---|---|---|---|---|---|---| -| GET `/api/shifts` | — | 200 | 200 | 200 | 200 | 200 | 200 | 200 | -| GET `/api/shifts/1/users` | — | | 200 | 403 | 403 | 403 | 403 | 403 | -| GET `/api/shifts/1/billing` | — | | 200 | 403 | 403 | 403 | 403 | | -| GET `/api/shifts/1/records` | — | | 200 | 200 | 200 | 403 | 403 | 403 | -| GET `/api/shifts/1/emails` | — | | 200 | 200 | 403 | 403 | 403 | | -| GET `/api/shifts/1/staff` | — | | 200 | 200 | 200 | 403 | 403 | | -| GET `/api/shifts/1/pdf` | — | | 200 | 403 | 403 | 403 | 403 | | -| GET `/api/shifts/1/tents` | — | | 200 | 200 | 200 | 403 | 403 | | -| GET `/api/shifts/1/tents/1` | — | | 200 | | 200 | 403 | 403 | | -| POST `/api/shifts/1/tents/2` | `{score: 5}` | | 201 | 201 | 201 | 403 | 403 | 403 | -| GET `/api/teams?shiftNr=1` | — | | 200 | | 200 | 403 | 403 | | -| POST `/api/teams` | `{shiftNr: 1, name: "Uus"}` | | | | 201 | 403 | 403 | | -| GET `/api/registrations?shiftNr=1` | — | | 200 | 200 | 200 | 200 | 200 | 200 | -| PATCH `/api/registrations/:regId` | `{isRegistered: true}` | | 204 | 404 | 404 | 404 | 404 | 404 | -| PATCH `/api/registrations/:regId` | `{pricePaid: 10}` | | 204 | 404 | | | | | -| POST `/api/registrations/sync` | — | 204 | 403 | | 403 | | | 403 | -| POST `/api/bills` | `{email: "bill-parent@test.invalid"}` | | 201 | 403 | 403 | 403 | | 403 | -| GET `/api/bills/:billNr` (from the 201 above) | — | | 200 | | 403 | | | | -| GET `/api/bills/999999` | — | | 404 | | | | | | -| POST `/api/notifications/bills` | `{email: "bill-parent@test.invalid"}` | | 204 | 403 | 403 | | | | -| POST `/api/notifications/bills` | `{email: "nobody@test.invalid"}` | | 404 | | | | | | -| POST `/api/records` | `{shiftNr: 1, forceSync: true}` | | 204 | 204 | 204 | 403 | 403 | 403 | -| POST `/api/records` | `{shiftNr: 1, forceSync: false}` | | 304 | | | | | | -| POST `/api/records` | `{shiftNr: 99, forceSync: true}` | | 403 | | 404 (helper has role in 99; shift missing) | | | | -| PATCH `/api/records/:recordId` | `{tentNr: 3}` | | 204 | 204 | 204 | 403 | 403 | 403 | -| PATCH `/api/records/999999` | `{tentNr: 3}` | | 404 | | | | | | -| DELETE `/api/grades/:gradeId` | — | | | | 204 | 403 | 403 | | -| DELETE `/api/grades/999999` | — | | 204 | | | | | | -| PATCH `/api/users/:ownId` | `{currentShift: 1}` | | 204 (self) | | | | | | -| PATCH `/api/users/:bossId` as instructor | `{currentShift: 1}` | | | 403 | | | | | -| PATCH `/api/users/:ownId` | `{currentShift: 2}` (not a member) | | 403 | | | | | | -| GET `/api/records?childId=:childId` | — | | 200 | 200 | 200 | | 403 | 403 | -| POST `/api/users/invites` | `{email:"x@test.invalid", name:"X", shiftNr:1, role:"helper"}` | | 204 | 403 | 403 | | 403 | | - -Blank cells = don't test that combination. The empty-body 403 from -`requireRoot` (sync row) should additionally assert `res.body === ""`. -Ordering constraints: run the two mutating PATCH-registration rows and -`POST /api/records` rows **after** the read-only rows (they change -`isRegistered` and records); the bills GET row needs the bills POST row first -— keep the cases array in the table's order and it works out, since -`GET /api/registrations` content assertions live in a different file (§7.5). - -Additional single tests in the same file: - -- Any protected URL without a cookie → 401 with - `{status:"fail", data:{message}}`. -- Unknown path `/api/nonsense` (with cookie) → 404 with `data.path`. -- `GET /api/registrations?shiftNr=1` as `norole` → 200 **and** - `data.registrations` is `[]` (pins the frozen "empty array instead of 403" - behaviour). -- `GET /api/shifts/1/pdf` as boss: assert `content-type` starts with - `application/pdf` and the payload is non-empty. -- After the `POST /api/notifications/bills` 204: the registered registration - has `notifSent: true` in the DB. - -### 7.5 `registration-visibility.test.ts` - -Fixtures: shift 1; users `boss`, `instructor`, `helper`, `viewer` (as in -§7.4); one registration with **all** optional fields populated (addendum, -backupTel, billId null, etc.). - -For `GET /api/registrations?shiftNr=1`, assert on the first element of -`data.registrations`: - -- as `boss`: `birthday`, `road`, `county`, `country`, `addendum`, - `pricePaid`, `priceToPay`, `notifSent`, `billId`, `contactName`, - `contactNumber`, `contactEmail`, `backupTel` all **present**; - `child.currentAge` is an integer. -- as `instructor` (contact only): `contactName`/`contactEmail`/ - `contactNumber` present; `birthday`, `road`, `pricePaid`, `priceToPay`, - `billId` **absent** — assert with `assert.ok(!("birthday" in reg))`, not - `undefined` equality. -- as `helper` and as `viewer` (basic only): all permission-gated fields - absent; `id`, `childId`, `child.name`, `child.sex`, `child.currentAge`, - `shiftNr`, `isRegistered`, `regOrder`, `isOld`, `tsSize` present. - -### 7.6 `registration-create.test.ts` - -Public endpoint — no login. Fixtures: shifts 1 and 2. Build app after -fixtures. - -- Happy path with idCode: POST one entry with a **valid** 11-digit child code - (construct one starting with `5`, digits 2–7 encoding a real date, any - 4-digit tail — the checksum is deliberately not validated) → 201, - `data.registrationId` is a UUID; DB: `children` row created with sex/ - birthYear derived from the code; `registrations` row has `priceToPay === - computePrice(shiftNr, isOld)`. -- Happy path without idCode but with `sex` + `dob` → 201. -- Invalid idCode (bad date inside) → 400, `status:"fail"`, fail data keyed - `"[0].idCode"`, and **no** child/registration rows created (transaction - rolled back). -- Missing sex+dob and no idCode → 400 with `"[0].sex"` / `"[0].dob"` keys. -- Duplicate: register the same child (same idCode) for the same shift twice - (two sequential requests) → both 201 (no leak), but the second - `registrations` row has `visible: false`. -- Five entries in the array → 400 (schema `maxItems: 4`). -- `regOrder`: two sequential requests get strictly increasing `regOrder` - values in the DB. - -### 7.7 `validation.test.ts` - -Login as `boss` (fixtures as in §7.4, one registration, one shift). - -- `PATCH /api/registrations/:regId` `{pricePaid: -5}` → **400** with - `status:"fail"` and fail-data key `pricePaid` (exercises §3.4 + the AJV - error handler mapping). -- Same with `{priceToPay: -1}` → 400. -- `POST /api/shifts/1/tents/1` `{score: -1}` → 400; `{score: 9999}` → 400. -- `PATCH /api/registrations/:regId` `{unknownField: 1}` → 400 - (`additionalProperties: false`). -- `GET /api/app/version?platform=windows` → 400 (public route, enum - validation). - -### 7.8 Existing files - -`test/smoke.test.ts` and `test/routes.test.ts` stay byte-identical. They pick -up the test DB automatically through the edited build helper. The route -snapshot must not change — none of the Phase 0 edits add/remove routes; if the -snapshot test fails, a Phase 0 edit went wrong (do **not** regenerate the -snapshot to make it pass). - -## 8. Phase 5 — GitHub Actions - -Create `.github/workflows/ci.yml` exactly: - -```yaml -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - ci: - runs-on: ubuntu-latest - services: - mariadb: - image: mariadb:11.4 - env: - MARIADB_DATABASE: ml_test - MARIADB_USER: ml_test - MARIADB_PASSWORD: ml_test - MARIADB_ROOT_PASSWORD: root - ports: - - 3307:3306 - options: >- - --health-cmd "healthcheck.sh --connect --innodb_initialized" - --health-interval 5s - --health-timeout 5s - --health-retries 12 - env: - DATABASE_URL: mysql://ml_test:ml_test@127.0.0.1:3307/ml_test - steps: - - uses: actions/checkout@v4 - - run: corepack enable - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: yarn - - run: yarn install --immutable - - run: yarn gen:email - - run: yarn prisma generate - - run: yarn lint - - run: yarn build - - run: yarn typecheck - - run: yarn prisma db push --skip-generate - - run: yarn test -``` - -Notes baked into this design: - -- `src/generated/**` is gitignored, so CI must run `gen:email` **and** - `prisma generate` before anything that type-checks `src` (lint is - type-aware; build is `tsc`). -- `corepack enable` must precede `setup-node` with `cache: yarn` (Yarn 4). -- `prisma.config.ts` reads `DATABASE_URL` at config load, hence the job-level - env — it is needed even for `prisma generate`. -- The test-env helper hardcodes host `127.0.0.1:3307`, matching the service - port mapping; no app-level env vars are needed in the workflow. - -## 9. Phase 6 — verification & acceptance - -1. `yarn test:db:up && yarn test:db:push` succeeds from a clean checkout - (plus `yarn gen:email` and `yarn prisma generate` if not yet run). -2. `yarn test` passes; run it **twice in a row** without re-pushing the schema - (proves `resetDb` isolation). -3. `yarn lint`, `yarn build`, `yarn typecheck` all pass. -4. Temporarily set `DATABASE_NAME=ml_dev` in the shell and confirm the suite - still targets `ml_test` (test-env overrides) — then unset. -5. The route snapshot test passes unmodified. -6. Full suite wall-clock under ~2 minutes locally. -7. Push a branch, open a PR, confirm the workflow is green. - -Definition of done: all of the above, plus a short summary in the PR/commit -description listing (a) the Phase 0 production changes, (b) the frozen quirks -the tests now pin, and (c) the §3.5 seed change (`EDIT_SHIFT_MEMBERS` now -granted to root and boss — run `yarn seed` against existing databases to -apply it). - -## 10. Commit plan - -One commit per phase, in order: - -1. `Add test-suite prerequisites` (Phase 0, §3.1–3.6) -2. `Add test DB compose file and scripts` (Phase 1) -3. `Add test helpers` (Phase 2) -4. `Add unit tests` (Phase 3) -5. `Add integration tests` (Phase 4) -6. `Add CI workflow` (Phase 5) - -Run the full verification (§9) before the final commit; fix forward within -the relevant phase's commit via amend or a follow-up `Fix …` commit.