Files
basket_utils/server/app.test.js
T

177 lines
24 KiB
JavaScript

import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createInitialPlay } from '../src/domain.js';
import { buildServer } from './app.js';
import { hashPassword } from './security.js';
function clone(value) { return structuredClone(value); }
function matches(document, filter) {
return Object.entries(filter).every(([key, expected]) => {
const actual = document[key];
if (expected && typeof expected === 'object' && !Array.isArray(expected)) {
if ('$gt' in expected && !(actual > expected.$gt)) return false;
if ('$lte' in expected && !(actual <= expected.$lte)) return false;
if ('$in' in expected && !expected.$in.includes(actual)) return false;
if ('$regex' in expected && !(new RegExp(expected.$regex, expected.$options || '')).test(String(actual ?? ''))) return false;
return true;
}
return actual === expected;
});
}
function project(document, projection = {}) {
if (!projection || !Object.keys(projection).length) return clone(document);
const include = Object.entries(projection).filter(([, value]) => value === 1).map(([key]) => key);
const result = include.length ? Object.fromEntries(include.filter((key) => key in document).map((key) => [key, clone(document[key])])) : clone(document);
if (projection._id === 0) delete result._id;
return result;
}
function collection(uniqueKeys = []) {
const documents = [];
return {
documents,
async createIndex() { return 'index'; },
async findOne(filter, options) { const document = documents.find((item) => matches(item, filter)); return document ? project(document, options?.projection) : null; },
find(filter, options) {
let result = documents.filter((item) => matches(item, filter)).map((item) => project(item, options?.projection));
return { sort(spec) { result.sort((left, right) => { for (const [key, direction] of Object.entries(spec)) { const a = left[key]; const b = right[key]; if (a === b) continue; return (a > b ? 1 : -1) * direction; } return 0; }); return this; }, limit(count) { result = result.slice(0, count); return this; }, toArray: async () => result };
},
async insertOne(document) { if (documents.some((item) => uniqueKeys.some((keys) => keys.every((key) => item[key] !== undefined && item[key] === document[key])))) { const error = new Error('duplicate'); error.code = 11000; throw error; } documents.push(clone(document)); return { acknowledged: true }; },
async updateOne(filter, update) { const document = documents.find((item) => matches(item, filter)); if (!document) return { modifiedCount: 0 }; Object.assign(document, clone(update.$set || {})); return { modifiedCount: 1 }; },
async findOneAndUpdate(filter, update, options) { let document = documents.find((item) => matches(item, filter)); let upserted = false; if (!document && options?.upsert) { document = { ...clone(filter), ...clone(update.$setOnInsert || {}) }; documents.push(document); upserted = true; } if (!document) return null; Object.assign(document, clone(update.$set || {})); const value = clone(document); return options?.includeResultMetadata ? { value, lastErrorObject: upserted ? { upserted: document.id } : {} } : value; },
async deleteOne(filter) { const index = documents.findIndex((item) => matches(item, filter)); if (index < 0) return { deletedCount: 0 }; documents.splice(index, 1); return { deletedCount: 1 }; },
async deleteMany(filter) { let deletedCount = 0; for (let index = documents.length - 1; index >= 0; index -= 1) if (matches(documents[index], filter)) { documents.splice(index, 1); deletedCount += 1; } return { deletedCount }; },
};
}
function memoryStore() { return { users: collection([['email']]), sessions: collection([['tokenHash']]), teams: collection(), teamMembers: collection([['teamId', 'userId']]), joinRequests: collection([['teamId', 'userId']]), plays: collection([['teamId', 'id']]), async close() {} }; }
function testConfig() { return { mongodb: { uri: 'mongodb://172.16.0.7:27017', db: 'basket_utils' }, server: { host: '0.0.0.0', port: 3000, mode: 'development', allowedOrigins: ['http://localhost:5173', 'http://127.0.0.1:5173', 'http://192.168.5.10:5173'], secureCookie: false }, bootstrap: { password: '' }, qa: { db: 'basket_utils_qa', password: '' } }; }
async function seedUser(store, { id, email, displayName, isOperator = false, status = 'approved', password = 'correct horse battery staple' }) {
const at = new Date(); await store.users.insertOne({ id, email, displayName, passwordHash: await hashPassword(password), status, isOperator, createdAt: at, updatedAt: at }); return { id, email, password };
}
async function login(app, email, password) { const response = await app.inject({ method: 'POST', url: '/api/auth/login', payload: { email, password } }); const setCookie = response.headers['set-cookie']; const cookieHeader = Array.isArray(setCookie) ? setCookie[0] : setCookie; return { response, cookie: cookieHeader?.split(';')[0] }; }
describe('Fastify Mongo contract', () => {
let app;
const temporaryRoots = [];
afterEach(async () => { await app?.close(); await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); });
it('uses a one hour sliding session and never revives an expired session', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
try {
const store = memoryStore(); await seedUser(store, { id: 'clock', email: 'clock@example.com', displayName: 'Clock' });
app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false });
const start = new Date('2026-01-01T00:00:00.000Z'); vi.setSystemTime(start);
const result = await login(app, 'clock@example.com', 'correct horse battery staple');
expect(result.response.headers['set-cookie']).toContain('Max-Age=3600');
const session = store.sessions.documents[0]; expect(session.expiresAt).toEqual(new Date(start.getTime() + 3_600_000));
vi.setSystemTime(new Date(start.getTime() + 50 * 60_000));
const renewed = await app.inject({ method: 'GET', url: '/api/teams', headers: { cookie: result.cookie } });
expect(renewed.statusCode).toBe(200); expect(session.expiresAt).toEqual(new Date(start.getTime() + 110 * 60_000));
vi.setSystemTime(new Date(start.getTime() + 70 * 60_000)); const activity = await app.inject({ method: 'POST', url: '/api/auth/activity', headers: { cookie: result.cookie }, payload: {} }); expect(activity.statusCode).toBe(200); expect(activity.headers['set-cookie']).toContain('Max-Age=3600');
vi.setSystemTime(new Date(start.getTime() + 130 * 60_000)); const expired = await app.inject({ method: 'POST', url: '/api/auth/activity', headers: { cookie: result.cookie }, payload: {} }); expect(expired.statusCode).toBe(401); expect(session.expiresAt).toEqual(new Date(start.getTime() + 130 * 60_000));
} finally { vi.useRealTimers(); }
});
it('does not renew on health, auth/me, or rejected origins', async () => {
const store = memoryStore(); await seedUser(store, { id: 'origin', email: 'origin@example.com', displayName: 'Origin' }); app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false });
const result = await login(app, 'origin@example.com', 'correct horse battery staple'); const session = store.sessions.documents[0]; const original = session.expiresAt;
expect((await app.inject({ method: 'GET', url: '/api/health', headers: { cookie: result.cookie } })).statusCode).toBe(200); expect(session.expiresAt).toEqual(original);
expect((await app.inject({ method: 'GET', url: '/api/auth/me', headers: { cookie: result.cookie } })).statusCode).toBe(200); expect(session.expiresAt).toEqual(original);
expect((await app.inject({ method: 'POST', url: '/api/auth/activity', headers: { cookie: result.cookie, origin: 'https://evil.example' }, payload: {} })).statusCode).toBe(403); expect(session.expiresAt).toEqual(original);
});
it('rejects a legacy session that has already been idle for an hour', async () => {
const store = memoryStore(); await seedUser(store, { id: 'legacy', email: 'legacy@example.com', displayName: 'Legacy' }); app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false });
const result = await login(app, 'legacy@example.com', 'correct horse battery staple'); const session = store.sessions.documents[0]; delete session.lastActivityAt; session.createdAt = new Date(Date.now() - 3_600_000); session.expiresAt = new Date(Date.now() + 86_400_000);
expect((await app.inject({ method: 'GET', url: '/api/teams', headers: { cookie: result.cookie } })).statusCode).toBe(401);
});
it('rejects unauthenticated and pending access', async () => {
const store = memoryStore(); app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false });
expect((await app.inject({ method: 'GET', url: '/api/teams' })).statusCode).toBe(401);
await app.inject({ method: 'POST', url: '/api/auth/register', payload: { email: 'pending@example.com', password: 'password123', displayName: 'Pending' } });
const pending = await login(app, 'pending@example.com', 'password123'); expect(pending.response.statusCode).toBe(403); expect(pending.response.json().error.code).toBe('approval_required');
});
it('enforces team ownership and isolates multi-team play save/load', async () => {
const store = memoryStore(); await seedUser(store, { id: 'u1', email: 'one@example.com', displayName: 'One' }); await seedUser(store, { id: 'u2', email: 'two@example.com', displayName: 'Two' });
app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false }); const one = await login(app, 'one@example.com', 'correct horse battery staple'); expect(one.response.statusCode).toBe(200);
const teamAResponse = await app.inject({ method: 'POST', url: '/api/teams', headers: { cookie: one.cookie }, payload: { name: 'Team A' } }); expect(teamAResponse.statusCode, teamAResponse.body).toBe(201); const teamA = teamAResponse.json().team; const teamBResponse = await app.inject({ method: 'POST', url: '/api/teams', headers: { cookie: one.cookie }, payload: { name: 'Team B' } }); expect(teamBResponse.statusCode, teamBResponse.body).toBe(201); const teamB = teamBResponse.json().team;
const play = createInitialPlay('A play'); const saved = await app.inject({ method: 'PUT', url: `/api/teams/${teamA.id}/plays/${play.id}`, headers: { cookie: one.cookie }, payload: { play } }); expect(saved.statusCode).toBe(201);
const loaded = await app.inject({ method: 'GET', url: `/api/teams/${teamA.id}/plays/${play.id}`, headers: { cookie: one.cookie } }); expect(loaded.statusCode).toBe(200); expect(loaded.json().play).toEqual(play);
const other = await login(app, 'two@example.com', 'correct horse battery staple'); const forbidden = await app.inject({ method: 'PUT', url: `/api/teams/${teamA.id}/plays/${play.id}`, headers: { cookie: other.cookie }, payload: { play } }); expect(forbidden.statusCode).toBe(404);
const teamBList = await app.inject({ method: 'GET', url: `/api/teams/${teamB.id}/plays`, headers: { cookie: one.cookie } }); expect(teamBList.json().plays).toEqual([]);
});
it('invalidates the session on logout and returns malformed plays as 400', async () => {
const store = memoryStore(); await seedUser(store, { id: 'u1', email: 'one@example.com', displayName: 'One' }); app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false }); const one = await login(app, 'one@example.com', 'correct horse battery staple');
const teamResponse = await app.inject({ method: 'POST', url: '/api/teams', headers: { cookie: one.cookie }, payload: { name: 'Team' } }); expect(teamResponse.statusCode, teamResponse.body).toBe(201); const team = teamResponse.json().team; const bad = await app.inject({ method: 'PUT', url: `/api/teams/${team.id}/plays/play-1`, headers: { cookie: one.cookie }, payload: { play: { id: 'play-1', players: [] } } }); expect(bad.statusCode).toBe(400);
expect((await app.inject({ method: 'POST', url: '/api/auth/logout', headers: { cookie: one.cookie }, payload: {} })).statusCode).toBe(200); expect((await app.inject({ method: 'GET', url: '/api/teams', headers: { cookie: one.cookie } })).statusCode).toBe(401);
});
it('supports operator approval, viewer read access, editor denial, and secure cookies', async () => {
const store = memoryStore(); await seedUser(store, { id: 'operator', email: 'operator@example.com', displayName: 'Operator', isOperator: true }); await seedUser(store, { id: 'viewer', email: 'viewer@example.com', displayName: 'Viewer', status: 'pending' });
app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: true }); const operator = await login(app, 'operator@example.com', 'correct horse battery staple'); const operatorCookie = operator.response.headers['set-cookie']; expect((Array.isArray(operatorCookie) ? operatorCookie : [operatorCookie]).some((value) => value.includes('Secure'))).toBe(true);
const pending = await app.inject({ method: 'GET', url: '/api/admin/users?status=pending', headers: { cookie: operator.cookie } }); expect(pending.json().users.map((user) => user.id)).toContain('viewer'); expect((await app.inject({ method: 'POST', url: '/api/admin/users/viewer/approve', headers: { cookie: operator.cookie }, payload: {} })).statusCode).toBe(200);
const team = (await app.inject({ method: 'POST', url: '/api/teams', headers: { cookie: operator.cookie, origin: 'http://192.168.5.10:5173' }, payload: { name: 'Shared' } })).json().team; const play = createInitialPlay('Shared play'); expect((await app.inject({ method: 'PUT', url: `/api/teams/${team.id}/plays/${play.id}`, headers: { cookie: operator.cookie }, payload: { play } })).statusCode).toBe(201);
await store.teamMembers.insertOne({ teamId: team.id, userId: 'viewer', role: 'viewer', createdAt: new Date() }); const viewer = await login(app, 'viewer@example.com', 'correct horse battery staple'); expect((await app.inject({ method: 'GET', url: `/api/teams/${team.id}/plays/${play.id}`, headers: { cookie: viewer.cookie } })).statusCode).toBe(200); expect((await app.inject({ method: 'PUT', url: `/api/teams/${team.id}/plays/${play.id}`, headers: { cookie: viewer.cookie }, payload: { play } })).statusCode).toBe(403);
expect((await app.inject({ method: 'POST', url: '/api/admin/users/viewer/suspend', headers: { cookie: operator.cookie }, payload: {} })).statusCode).toBe(200); expect((await app.inject({ method: 'GET', url: '/api/teams', headers: { cookie: viewer.cookie } })).statusCode).toBe(401);
});
it('completes registration, operator approval, join approval, and role changes', async () => {
const store = memoryStore(); await seedUser(store, { id: 'owner', email: 'owner@example.com', displayName: 'Owner' }); await seedUser(store, { id: 'operator', email: 'operator@example.com', displayName: 'Operator', isOperator: true });
app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false });
const registered = await app.inject({ method: 'POST', url: '/api/auth/register', payload: { email: 'new@example.com', password: 'password123', displayName: 'New User' } }); expect(registered.statusCode).toBe(201); expect(registered.json().user.status).toBe('pending');
const operator = await login(app, 'operator@example.com', 'correct horse battery staple'); const userId = registered.json().user.id;
expect((await app.inject({ method: 'POST', url: `/api/admin/users/${userId}/approve`, headers: { cookie: operator.cookie }, payload: {} })).statusCode).toBe(200);
const owner = await login(app, 'owner@example.com', 'correct horse battery staple'); const team = (await app.inject({ method: 'POST', url: '/api/teams', headers: { cookie: owner.cookie }, payload: { name: 'North [A]' } })).json().team;
const applicant = await login(app, 'new@example.com', 'password123'); const search = await app.inject({ method: 'GET', url: '/api/teams/search?q=North%20%5BA%5D', headers: { cookie: applicant.cookie } }); expect(search.statusCode).toBe(200); expect(search.json().teams).toEqual([{ id: team.id, name: team.name }]);
const request = await app.inject({ method: 'POST', url: `/api/teams/${team.id}/join-requests`, headers: { cookie: applicant.cookie }, payload: {} }); expect(request.statusCode).toBe(201); const requestId = request.json().request.id;
const repeatedRequest = await app.inject({ method: 'POST', url: `/api/teams/${team.id}/join-requests`, headers: { cookie: applicant.cookie }, payload: {} }); expect(repeatedRequest.statusCode).toBe(200); expect(repeatedRequest.json().request.id).toBe(requestId);
const ownPendingRequests = (await app.inject({ method: 'GET', url: '/api/team-requests', headers: { cookie: applicant.cookie } })).json().requests; expect(ownPendingRequests[0].status).toBe('pending'); expect(ownPendingRequests[0].teamName).toBe('North [A]');
expect((await app.inject({ method: 'GET', url: `/api/teams/${team.id}/join-requests`, headers: { cookie: owner.cookie } })).json().requests).toHaveLength(1);
const approved = await app.inject({ method: 'POST', url: `/api/teams/${team.id}/join-requests/${requestId}/approve`, headers: { cookie: owner.cookie }, payload: { role: 'editor' } }); expect(approved.statusCode).toBe(200); expect(approved.json().member.role).toBe('editor');
const repeatedApproval = await app.inject({ method: 'POST', url: `/api/teams/${team.id}/join-requests/${requestId}/approve`, headers: { cookie: owner.cookie }, payload: { role: 'viewer' } }); expect(repeatedApproval.statusCode).toBe(200); expect(repeatedApproval.json().member.role).toBe('editor'); expect(store.joinRequests.documents.find((row) => row.id === requestId).role).toBe('editor');
expect((await app.inject({ method: 'PATCH', url: `/api/teams/${team.id}/members/${userId}`, headers: { cookie: applicant.cookie }, payload: { role: 'editor' } })).statusCode).toBe(403);
const editorPlay = createInitialPlay('Editor play'); expect((await app.inject({ method: 'PUT', url: `/api/teams/${team.id}/plays/${editorPlay.id}`, headers: { cookie: applicant.cookie }, payload: { play: editorPlay } })).statusCode).toBe(201);
expect((await app.inject({ method: 'PATCH', url: `/api/teams/${team.id}/members/${userId}`, headers: { cookie: owner.cookie }, payload: { role: 'viewer' } })).statusCode).toBe(200);
expect((await app.inject({ method: 'GET', url: '/api/teams', headers: { cookie: applicant.cookie } })).json().teams.map((entry) => entry.id)).toContain(team.id);
expect((await app.inject({ method: 'GET', url: `/api/teams/${team.id}/members`, headers: { cookie: owner.cookie } })).json().members.map((entry) => entry.user.id)).toContain(userId);
const ownApprovedRequests = (await app.inject({ method: 'GET', url: '/api/team-requests', headers: { cookie: applicant.cookie } })).json().requests; expect(ownApprovedRequests[0].status).toBe('approved'); expect(ownApprovedRequests[0].teamName).toBe('North [A]');
expect((await app.inject({ method: 'PUT', url: `/api/teams/${team.id}/plays/${editorPlay.id}`, headers: { cookie: applicant.cookie }, payload: { play: editorPlay } })).statusCode).toBe(403);
expect((await app.inject({ method: 'PATCH', url: `/api/teams/${team.id}/members/${userId}`, headers: { cookie: owner.cookie }, payload: { role: 'owner' } })).statusCode).toBe(400);
expect((await app.inject({ method: 'PATCH', url: `/api/teams/${team.id}/members/${userId}`, headers: { cookie: owner.cookie }, payload: { role: 'bad' } })).statusCode).toBe(400);
});
it('keeps teams isolated and refuses suspended applicants and nonmembers', async () => {
const store = memoryStore(); await seedUser(store, { id: 'owner', email: 'owner@example.com', displayName: 'Owner' }); await seedUser(store, { id: 'applicant', email: 'applicant@example.com', displayName: 'Applicant' }); await seedUser(store, { id: 'viewer', email: 'viewer@example.com', displayName: 'Viewer' });
app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false }); const owner = await login(app, 'owner@example.com', 'correct horse battery staple'); const applicant = await login(app, 'applicant@example.com', 'correct horse battery staple'); const viewer = await login(app, 'viewer@example.com', 'correct horse battery staple');
const first = (await app.inject({ method: 'POST', url: '/api/teams', headers: { cookie: owner.cookie }, payload: { name: 'First Team' } })).json().team; const second = (await app.inject({ method: 'POST', url: '/api/teams', headers: { cookie: owner.cookie }, payload: { name: 'Second Team' } })).json().team;
const request = await app.inject({ method: 'POST', url: `/api/teams/${first.id}/join-requests`, headers: { cookie: applicant.cookie }, payload: {} }); const requestId = request.json().request.id;
await store.users.updateOne({ id: 'applicant' }, { $set: { status: 'suspended' } }); expect((await app.inject({ method: 'POST', url: `/api/teams/${first.id}/join-requests/${requestId}/approve`, headers: { cookie: owner.cookie }, payload: {} })).statusCode).toBe(409);
expect((await app.inject({ method: 'GET', url: `/api/teams/${first.id}`, headers: { cookie: viewer.cookie } })).statusCode).toBe(404);
expect((await app.inject({ method: 'PATCH', url: `/api/teams/${first.id}/members/owner`, headers: { cookie: owner.cookie }, payload: { role: 'viewer' } })).statusCode).toBe(400);
expect((await app.inject({ method: 'GET', url: `/api/teams/${second.id}/members`, headers: { cookie: viewer.cookie } })).statusCode).toBe(404);
});
it('caps team search and preserves the inserted role on concurrent approval', async () => {
const store = memoryStore(); await seedUser(store, { id: 'owner', email: 'owner@example.com', displayName: 'Owner' }); await seedUser(store, { id: 'applicant', email: 'applicant@example.com', displayName: 'Applicant' });
app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false }); const owner = await login(app, 'owner@example.com', 'correct horse battery staple'); const applicant = await login(app, 'applicant@example.com', 'correct horse battery staple');
const teams = []; for (let index = 0; index < 21; index += 1) teams.push((await app.inject({ method: 'POST', url: '/api/teams', headers: { cookie: owner.cookie }, payload: { name: `Cap Team ${String(index).padStart(2, '0')}` } })).json().team);
expect((await app.inject({ method: 'GET', url: '/api/teams/search?q=Cap', headers: { cookie: applicant.cookie } })).json().teams).toHaveLength(20);
const request = await app.inject({ method: 'POST', url: `/api/teams/${teams[0].id}/join-requests`, headers: { cookie: applicant.cookie }, payload: {} }); const requestId = request.json().request.id;
const approvals = await Promise.all(['viewer', 'editor'].map((role) => app.inject({ method: 'POST', url: `/api/teams/${teams[0].id}/join-requests/${requestId}/approve`, headers: { cookie: owner.cookie }, payload: { role } })));
expect(approvals.every((response) => response.statusCode === 200)).toBe(true); const member = store.teamMembers.documents.find((row) => row.teamId === teams[0].id && row.userId === 'applicant'); expect(['viewer', 'editor']).toContain(member.role); expect(store.joinRequests.documents.find((row) => row.id === requestId).role).toBe(member.role);
});
it('does not expose config files through the static handler', async () => {
const staticRoot = await mkdtemp(path.join(os.tmpdir(), 'basket-utils-static-')); temporaryRoots.push(staticRoot);
await writeFile(path.join(staticRoot, 'index.html'), 'safe'); await writeFile(path.join(staticRoot, '.config.json'), '{"bootstrap":{"password":"static-secret"}}');
app = await buildServer({ config: testConfig(), db: memoryStore(), staticRoot });
for (const url of ['/.config.json', '/.config.json.sample', '/nested/.config.json.bak']) {
const response = await app.inject({ method: 'GET', url }); expect(response.statusCode).toBe(404); expect(response.body).not.toContain('static-secret');
}
});
});