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 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/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/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/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; 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..fcc4444 --- /dev/null +++ b/test/helpers/db.ts @@ -0,0 +1,54 @@ +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"`, + ); + } + + // 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); +}; + +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; 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/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); +}); 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); +}); 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"] } 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); +});