import { expect, it } from 'vitest'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { io } from 'socket.io-client'; import { FileStore } from '../server/store.mjs'; import { createService } from '../server/service.mjs'; import { termsCookie, termsAcceptance, TERMS_MAX_AGE, } from '../server/terms.mjs'; import { hasCurrentTermsCookie, terms, termsReturnPath, } from '../shared/terms.mjs'; const secret = 'terms-integration-secret-at-least-32-characters'; it('acceptance rejects tampering, old versions, future dates and expiration', () => { const now = Date.now(); const cookie = termsCookie(secret, true, now); expect(cookie).toContain('HttpOnly; SameSite=Lax'); expect(cookie).toContain('; Secure'); expect(termsAcceptance(cookie, secret, now).accepted).toBe(true); expect(termsAcceptance(cookie, 'another-secret', now).accepted).toBe(false); expect( termsAcceptance(cookie.replace(terms.version, '2025-01-01.1'), secret, now) .accepted, ).toBe(false); expect( termsAcceptance(termsCookie(secret, false, now + 60_000), secret, now) .accepted, ).toBe(false); expect( termsAcceptance(cookie, secret, now + TERMS_MAX_AGE * 1000).accepted, ).toBe(false); expect( termsAcceptance('juegoland_terms=accepted', secret, now).accepted, ).toBe(false); expect(termsAcceptance('', secret, now).accepted).toBe(false); const value = cookie.split(';')[0].slice('juegoland_terms='.length); expect(hasCurrentTermsCookie(value, now)).toBe(true); expect(hasCurrentTermsCookie('', now)).toBe(false); expect( hasCurrentTermsCookie(value.replace(terms.version, '2025-01-01.1'), now), ).toBe(false); expect(hasCurrentTermsCookie(value, now + TERMS_MAX_AGE * 1000)).toBe(false); // The SSR hint never grants access: a plausible but forged cookie is rejected. const forged = value.slice(0, -64) + '0'.repeat(64); expect(hasCurrentTermsCookie(forged, now)).toBe(true); expect( termsAcceptance('juegoland_terms=' + forged, secret, now).accepted, ).toBe(false); }); it('requires explicit current acceptance in HTTP and WebSockets, persists it and revokes access', async () => { const directory = await mkdtemp(join(tmpdir(), 'juegoland-terms-')); const store = new FileStore(join(directory, 'store.json')); await store.init(); const origin = 'https://juegoland.imaginacion.com'; const lanOrigin = 'http://192.168.18.155:3000'; const service = createService( store, { NODE_ENV: 'production', APP_ORIGIN: `${origin},${lanOrigin}`, PIN_PEPPER: secret, }, (_req, res) => { res.writeHead(200, { 'content-type': 'text/html' }); res.end('

Juegoland

'); }, ); await new Promise((resolve) => service.server.listen(0, '127.0.0.1', resolve), ); const address = service.server.address(); if (!address || typeof address === 'string') throw new Error('Missing port'); const base = `http://127.0.0.1:${address.port}`; const request = ( path: string, cookie = '', data?: unknown, requestOrigin = origin, ) => fetch(base + '/api' + path, { method: data === undefined ? 'GET' : 'POST', headers: { cookie, origin: requestOrigin, 'content-type': 'application/json', }, body: data === undefined ? undefined : JSON.stringify(data), }); const sockets: ReturnType[] = []; try { expect(await (await fetch(base + '/')).text()).toBe('

Juegoland

'); expect((await request('/health')).status).toBe(200); expect((await request('/catalog')).status).toBe(200); expect((await request('/rooms')).status).toBe(200); expect((await (await request('/session')).json()).terms.accepted).toBe( false, ); expect( (await request('/auth/guest', '', { name: 'Test player' })).status, ).toBe(428); expect( ( await request('/terms/accept', '', { accepted: false, version: terms.version, }) ).status, ).toBe(400); expect( (await request('/terms/accept', '', { accepted: true, version: 'old' })) .status, ).toBe(400); expect( ( await request( '/terms/accept', '', { accepted: true, version: terms.version }, 'https://other.example', ) ).status, ).toBe(403); const nativeAcceptance = ( fields: Record, requestOrigin = origin, ) => fetch(base + '/api/terms/accept', { method: 'POST', redirect: 'manual', headers: { origin: requestOrigin, 'content-type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams(fields), }); const formFields = { accepted: 'true', version: terms.version, returnTo: '/?invite=abc123', }; const nativeAccepted = await nativeAcceptance(formFields); expect(nativeAccepted.status).toBe(303); expect(nativeAccepted.headers.get('location')).toBe('/?invite=abc123'); expect(nativeAccepted.headers.get('cache-control')).toBe('no-store'); expect(nativeAccepted.headers.get('set-cookie')).toContain('; Secure'); const nativeCookie = nativeAccepted.headers .get('set-cookie')! .split(';')[0]; expect( (await (await request('/session', nativeCookie)).json()).terms.accepted, ).toBe(true); for (const fields of [ { ...formFields, accepted: 'false' }, { version: terms.version }, { ...formFields, version: 'old' }, ]) { const rejected = await nativeAcceptance(fields); expect(rejected.status).toBe(400); expect(rejected.headers.get('set-cookie')).toBeNull(); } expect( (await nativeAcceptance(formFields, 'https://other.example')).status, ).toBe(403); const safeReturn = await nativeAcceptance({ ...formFields, returnTo: 'https://other.example/', }); expect(safeReturn.headers.get('location')).toBe('/'); const lanAcceptance = await request( '/terms/accept', '', { accepted: true, version: terms.version, }, lanOrigin, ); expect(lanAcceptance.status).toBe(200); expect(lanAcceptance.headers.get('set-cookie')).not.toContain('; Secure'); const lanCookie = lanAcceptance.headers.get('set-cookie')!.split(';')[0]; const lanGuest = await request( '/auth/guest', lanCookie, { name: 'LAN player' }, lanOrigin, ); expect(lanGuest.status).toBe(201); expect(lanGuest.headers.get('set-cookie')).not.toContain('; Secure'); const lanSocket = io(base, { forceNew: true, reconnection: false, transports: ['websocket'], extraHeaders: { origin: lanOrigin, cookie: lanCookie + '; ' + lanGuest.headers.get('set-cookie')!.split(';')[0], }, }); sockets.push(lanSocket); await new Promise((resolve, reject) => { lanSocket.once('connect', resolve); lanSocket.once('connect_error', reject); }); const accepted = await request('/terms/accept', '', { accepted: true, version: terms.version, }); expect(accepted.status).toBe(200); expect(accepted.headers.get('set-cookie')).toContain('; Secure'); const cookie = accepted.headers.get('set-cookie')!.split(';')[0]; const date = (await accepted.json()).terms.acceptedAt; expect( (await (await request('/session', cookie)).json()).terms.acceptedAt, ).toBe(date); const guest = await request('/auth/guest', cookie, { name: 'Test player' }); expect(guest.status).toBe(201); const user = (await guest.json()).user; expect((await store.userById(user.id)).termsAcceptance).toEqual({ version: terms.version, acceptedAt: date, }); const sessionCookie = guest.headers.get('set-cookie')!.split(';')[0]; const blocked = io(base, { forceNew: true, reconnection: false, transports: ['websocket'], extraHeaders: { origin, cookie: sessionCookie }, }); sockets.push(blocked); const connectionError = await new Promise((resolve) => blocked.once('connect_error', resolve), ); expect(connectionError.message).toContain('condiciones'); const combined = `${sessionCookie}; ${cookie}`; const socket = io(base, { forceNew: true, reconnection: false, transports: ['websocket'], extraHeaders: { origin, cookie: combined }, }); sockets.push(socket); await new Promise((resolve, reject) => { socket.once('connect', resolve); socket.once('connect_error', reject); }); const disconnected = new Promise((resolve) => socket.once('disconnect', () => resolve()), ); const revoked = await request('/terms/revoke', combined, {}); expect(revoked.status).toBe(200); expect(revoked.headers.get('set-cookie')).toContain('Max-Age=0'); await disconnected; expect((await store.userById(user.id)).termsAcceptance).toBeUndefined(); expect((await request('/profile', sessionCookie)).status).toBe(428); expect((await request('/auth/logout', sessionCookie, {})).status).toBe(200); } finally { sockets.forEach((socket) => socket.disconnect()); await service.close(); await store.close(); await rm(directory, { recursive: true, force: true }); } }, 15000); it('keeps invitation links when reading terms and rejects external return URLs', () => { const origin = 'https://juegoland.imaginacion.com'; expect(termsReturnPath('/?invite=abc123', origin)).toBe('/?invite=abc123'); expect(termsReturnPath('/salas/demo', origin)).toBe('/salas/demo'); for (const value of [ '//other.example/', '/\\other.example/', 'javascript:alert(1)', 'https://other.example/', null, ]) expect(termsReturnPath(value, origin)).toBe('/'); });