You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

284 lines
9.8 KiB

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('<h1>Juegoland</h1>');
},
);
await new Promise<void>((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<typeof io>[] = [];
try {
expect(await (await fetch(base + '/')).text()).toBe('<h1>Juegoland</h1>');
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<string, string>,
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<void>((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<Error>((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<void>((resolve, reject) => {
socket.once('connect', resolve);
socket.once('connect_error', reject);
});
const disconnected = new Promise<void>((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('/');
});

Powered by TurnKey Linux.