test/auth.test.js
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect, afterEach, vi } from 'vitest';
import { writeFileSync, rmSync } from 'fs';
import * as auth from '../server/auth.js';
// USERS_PATH points at the hermetic throwaway path from test/setup.js.
// Remove the file AND call loadUsers() so the module's mtime cache resets to
// the no-file state (otherwise last-known-good could bleed across tests).
afterEach(() => {
try { rmSync(auth.USERS_PATH, { force: true }); } catch {}
auth.loadUsers();
});
describe('auth core', () => {
it('authEnabled is false when no users file exists', () => {
expect(auth.authEnabled()).toBe(false);
});
it('hashToken is deterministic and does not leak the token', () => {
const t = auth.generateToken();
expect(auth.hashToken(t)).toBe(auth.hashToken(t));
expect(auth.hashToken(t)).not.toContain(t);
expect(auth.hashToken(t)).toHaveLength(64);
});
it('resolves a valid token and rejects wrong/empty once a user exists', () => {
const token = auth.generateToken();
writeFileSync(auth.USERS_PATH, JSON.stringify([
{ id: 'u_a', displayName: 'Alice', color: '#d4a847', tokenHash: auth.hashToken(token) },
]));
expect(auth.authEnabled()).toBe(true);
expect(auth.resolveToken(token).displayName).toBe('Alice');
expect(auth.resolveToken('wrong-token')).toBeNull();
expect(auth.resolveToken('')).toBeNull();
expect(auth.resolveToken(null)).toBeNull();
});
it('publicUser strips the token hash', () => {
const u = { id: 'u_a', displayName: 'Alice', color: '#d4a847', tokenHash: 'deadbeef' };
expect(auth.publicUser(u)).toEqual({ id: 'u_a', displayName: 'Alice', color: '#d4a847' });
expect('tokenHash' in auth.publicUser(u)).toBe(false);
});
it('tokenFromRequest prefers the Authorization header, then ?token=', () => {
expect(auth.tokenFromRequest({ headers: { authorization: 'Bearer abc' }, url: '/' })).toBe('abc');
// header wins over query
expect(auth.tokenFromRequest({ headers: { authorization: 'Bearer abc' }, url: '/?token=xyz' })).toBe('abc');
// non-Bearer header falls through to the query param
expect(auth.tokenFromRequest({ headers: { authorization: 'Basic zzz' }, url: '/?token=xyz' })).toBe('xyz');
expect(auth.tokenFromRequest({ headers: {}, url: '/?token=xyz' })).toBe('xyz');
expect(auth.tokenFromRequest({ headers: {}, url: '/' })).toBeNull();
// malformed url must not throw
expect(auth.tokenFromRequest({ headers: {}, url: undefined })).toBeNull();
});
it('unwraps the { users: [...] } object form', () => {
const token = auth.generateToken();
writeFileSync(auth.USERS_PATH, JSON.stringify({ users: [
{ id: 'u_a', displayName: 'Alice', color: '#d4a847', tokenHash: auth.hashToken(token) },
] }));
expect(auth.loadUsers()).toHaveLength(1);
expect(auth.resolveToken(token).displayName).toBe('Alice');
writeFileSync(auth.USERS_PATH, JSON.stringify({ users: 'nope' }));
expect(auth.loadUsers()).toEqual([]);
});
it('skips malformed user records (missing/short tokenHash) without throwing', () => {
const token = auth.generateToken();
writeFileSync(auth.USERS_PATH, JSON.stringify([
{ id: 'u_bad', displayName: 'NoHash' },
{ id: 'u_short', displayName: 'Short', tokenHash: 'abc' },
{ id: 'u_ok', displayName: 'Ok', color: '#d4a847', tokenHash: auth.hashToken(token) },
]));
expect(auth.resolveToken(token).id).toBe('u_ok');
expect(auth.resolveToken('whatever')).toBeNull();
});
it('picks up users added/removed while running (mtime+size cache)', () => {
const token = auth.generateToken();
writeFileSync(auth.USERS_PATH, JSON.stringify([
{ id: 'u_a', displayName: 'A', color: '#d4a847', tokenHash: auth.hashToken(token) },
]));
expect(auth.loadUsers()).toHaveLength(1);
writeFileSync(auth.USERS_PATH, JSON.stringify([
{ id: 'u_a', displayName: 'A', color: '#d4a847', tokenHash: auth.hashToken(token) },
{ id: 'u_b', displayName: 'B', color: '#58a6ff', tokenHash: auth.hashToken(auth.generateToken()) },
]));
expect(auth.loadUsers()).toHaveLength(2);
rmSync(auth.USERS_PATH, { force: true });
expect(auth.authEnabled()).toBe(false);
});
it('FAILS CLOSED when users.json exists but is corrupt (no prior good copy)', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
writeFileSync(auth.USERS_PATH, '{ not valid json');
// auth stays ON (do not silently disable), but no token resolves
expect(auth.authEnabled()).toBe(true);
expect(auth.resolveToken('anything')).toBeNull();
expect(auth.resolveToken(auth.generateToken())).toBeNull();
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it('serves last-known-good users if the file becomes corrupt', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const token = auth.generateToken();
writeFileSync(auth.USERS_PATH, JSON.stringify([
{ id: 'u_a', displayName: 'Alice', color: '#d4a847', tokenHash: auth.hashToken(token) },
]));
expect(auth.resolveToken(token).displayName).toBe('Alice'); // caches good copy
writeFileSync(auth.USERS_PATH, 'CORRUPT');
// still resolves the previously-valid token rather than locking everyone out
expect(auth.resolveToken(token).displayName).toBe('Alice');
warn.mockRestore();
});
});