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
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('/');
|
|
});
|