Files
heygo/tests/session.test.ts
2026-06-20 20:06:10 +10:00

341 lines
9.6 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
AUTH_SESSION_COOKIE_NAME,
AuthError,
getCurrentUser,
getConfiguredAdminEmails,
hashSessionToken,
isConfiguredAdminEmail,
parseCookieHeader,
requireAdmin,
requireUser,
} from '../worker/auth';
import type { Env } from '../worker/env';
type SessionRow = {
id: string;
email: string | null;
name: string | null;
image_url: string | null;
role: 'user' | 'admin';
expires_at: string;
session_token_hash: string;
};
class FakeSessionD1 {
constructor(private readonly rows: SessionRow[]) {}
prepare(sql: string): FakeSessionStatement {
return new FakeSessionStatement(this.rows, sql);
}
}
class FakeSessionStatement {
private params: unknown[] = [];
constructor(
private readonly rows: SessionRow[],
private readonly sql: string,
) {}
bind(...params: unknown[]): this {
this.params = params;
return this;
}
async first<T>(): Promise<T | null> {
expect(this.sql).toContain('session_token_hash');
expect(this.sql).toContain('JOIN users');
expect(this.sql).toContain('expires_at');
const hash = String(this.params[0]);
const row = this.rows.find((candidate) => candidate.session_token_hash === hash);
if (!row) {
return null;
}
return {
id: row.id,
email: row.email,
name: row.name,
image_url: row.image_url,
role: row.role,
expires_at: row.expires_at,
} as T;
}
}
function makeEnv(rows: SessionRow[] = [], overrides: Partial<Env> = {}): { env: Env; db: FakeSessionD1 } {
const db = new FakeSessionD1(rows);
return {
env: {
DB: db as unknown as D1Database,
PUBLIC_HOST: 'heygo.cc',
PRIVATE_HOST: 'my.heygo.cc',
APP_BASE_URL: 'https://heygo.cc',
COOKIE_DOMAIN: '.heygo.cc',
...overrides,
},
db,
};
}
function makeRequest(cookieHeader?: string | null): Request {
const headers = new Headers();
if (cookieHeader != null) {
headers.set('cookie', cookieHeader);
}
return new Request('https://my.heygo.cc/api/me', { headers });
}
function futureIso(): string {
return new Date(Date.now() + 60 * 60 * 1000).toISOString();
}
function pastIso(): string {
return new Date(Date.now() - 60 * 60 * 1000).toISOString();
}
describe('parseCookieHeader', () => {
it('returns empty object for null/undefined/empty', () => {
expect(parseCookieHeader(null)).toEqual({});
expect(parseCookieHeader(undefined)).toEqual({});
expect(parseCookieHeader('')).toEqual({});
});
it('parses multiple cookies', () => {
expect(parseCookieHeader('a=1; b=2; c=hello')).toEqual({
a: '1',
b: '2',
c: 'hello',
});
});
it('trims whitespace around keys and values', () => {
expect(parseCookieHeader(' a = 1 ; b = 2 ')).toEqual({
a: '1',
b: '2',
});
});
});
describe('hashSessionToken', () => {
it('is deterministic and matches known SHA-256 for "hello"', async () => {
const hash = await hashSessionToken('hello');
expect(hash).toBe('2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824');
});
it('returns lowercase hex', async () => {
const hash = await hashSessionToken('SomeMixedCaseToken!');
expect(hash).toMatch(/^[0-9a-f]{64}$/);
});
});
describe('configured admin emails', () => {
it('parses comma, semicolon, and whitespace separated admin emails case-insensitively', () => {
const emails = getConfiguredAdminEmails({
ADMIN_EMAILS: ' Boss@Heygo.cc, owner@example.com; ops@example.com\n',
});
expect([...emails]).toEqual(['boss@heygo.cc', 'owner@example.com', 'ops@example.com']);
expect(isConfiguredAdminEmail('boss@heygo.cc', { ADMIN_EMAILS: 'Boss@Heygo.cc' })).toBe(true);
expect(isConfiguredAdminEmail('BOSS@HEYGO.CC', { ADMIN_EMAILS: 'boss@heygo.cc' })).toBe(true);
expect(isConfiguredAdminEmail('person@heygo.cc', { ADMIN_EMAILS: 'boss@heygo.cc' })).toBe(false);
});
});
describe('getCurrentUser', () => {
it('returns null when there is no Cookie header', async () => {
const { env } = makeEnv();
const user = await getCurrentUser(makeRequest(), env);
expect(user).toBeNull();
});
it('returns null when the session cookie is missing', async () => {
const { env } = makeEnv();
const user = await getCurrentUser(makeRequest('other=1'), env);
expect(user).toBeNull();
});
it('returns null when the session token has no matching session row', async () => {
const { env } = makeEnv();
const cookie = `${AUTH_SESSION_COOKIE_NAME}=unknown-token`;
const user = await getCurrentUser(makeRequest(cookie), env);
expect(user).toBeNull();
});
it('returns the user for a valid, non-expired session', async () => {
const token = 'valid-session-token';
const hash = await hashSessionToken(token);
const { env } = makeEnv([
{
id: 'user_1',
email: 'admin@heygo.cc',
name: 'Admin',
image_url: 'https://example.com/avatar.png',
role: 'admin',
expires_at: futureIso(),
session_token_hash: hash,
},
]);
const cookie = `${AUTH_SESSION_COOKIE_NAME}=${token}`;
const user = await getCurrentUser(makeRequest(cookie), env);
expect(user).toEqual({
id: 'user_1',
email: 'admin@heygo.cc',
name: 'Admin',
imageUrl: 'https://example.com/avatar.png',
role: 'admin',
});
});
it('treats a configured admin email as admin even when the database role is user', async () => {
const token = 'configured-admin-token';
const hash = await hashSessionToken(token);
const { env } = makeEnv(
[
{
id: 'user_configured_admin',
email: 'Boss@Heygo.cc',
name: 'Boss',
image_url: null,
role: 'user',
expires_at: futureIso(),
session_token_hash: hash,
},
],
{ ADMIN_EMAILS: 'boss@heygo.cc' },
);
const cookie = `${AUTH_SESSION_COOKIE_NAME}=${token}`;
const user = await getCurrentUser(makeRequest(cookie), env);
expect(user?.role).toBe('admin');
});
it('returns null for an expired session', async () => {
const token = 'expired-session-token';
const hash = await hashSessionToken(token);
const { env } = makeEnv([
{
id: 'user_2',
email: 'user@heygo.cc',
name: 'User',
image_url: null,
role: 'user',
expires_at: pastIso(),
session_token_hash: hash,
},
]);
const cookie = `${AUTH_SESSION_COOKIE_NAME}=${token}`;
const user = await getCurrentUser(makeRequest(cookie), env);
expect(user).toBeNull();
});
});
describe('requireUser', () => {
it('throws AuthError when unauthenticated', async () => {
const { env } = makeEnv();
await expect(requireUser(makeRequest(), env)).rejects.toThrow(AuthError);
await expect(requireUser(makeRequest(), env)).rejects.toMatchObject({ status: 401 });
});
it('returns the user when authenticated', async () => {
const token = 'valid-user-token';
const hash = await hashSessionToken(token);
const { env } = makeEnv([
{
id: 'user_3',
email: 'person@heygo.cc',
name: 'Person',
image_url: null,
role: 'user',
expires_at: futureIso(),
session_token_hash: hash,
},
]);
const cookie = `${AUTH_SESSION_COOKIE_NAME}=${token}`;
const user = await requireUser(makeRequest(cookie), env);
expect(user.id).toBe('user_3');
});
});
describe('requireAdmin', () => {
it('rejects an unauthenticated request', async () => {
const { env } = makeEnv();
await expect(requireAdmin(makeRequest(), env)).rejects.toThrow(AuthError);
await expect(requireAdmin(makeRequest(), env)).rejects.toMatchObject({ status: 401 });
});
it('rejects a normal (non-admin) user', async () => {
const token = 'normal-user-token';
const hash = await hashSessionToken(token);
const { env } = makeEnv([
{
id: 'user_4',
email: 'normal@heygo.cc',
name: 'Normal',
image_url: null,
role: 'user',
expires_at: futureIso(),
session_token_hash: hash,
},
]);
const cookie = `${AUTH_SESSION_COOKIE_NAME}=${token}`;
await expect(requireAdmin(makeRequest(cookie), env)).rejects.toThrow(AuthError);
await expect(requireAdmin(makeRequest(cookie), env)).rejects.toMatchObject({ status: 403 });
});
it('returns the admin user for an admin session', async () => {
const token = 'admin-session-token';
const hash = await hashSessionToken(token);
const { env } = makeEnv([
{
id: 'user_5',
email: 'boss@heygo.cc',
name: 'Boss',
image_url: null,
role: 'admin',
expires_at: futureIso(),
session_token_hash: hash,
},
]);
const cookie = `${AUTH_SESSION_COOKIE_NAME}=${token}`;
const user = await requireAdmin(makeRequest(cookie), env);
expect(user.id).toBe('user_5');
expect(user.role).toBe('admin');
});
it('allows a configured admin email through requireAdmin', async () => {
const token = 'configured-admin-require-token';
const hash = await hashSessionToken(token);
const { env } = makeEnv(
[
{
id: 'user_6',
email: 'owner@heygo.cc',
name: 'Owner',
image_url: null,
role: 'user',
expires_at: futureIso(),
session_token_hash: hash,
},
],
{ ADMIN_EMAILS: 'owner@heygo.cc' },
);
const cookie = `${AUTH_SESSION_COOKIE_NAME}=${token}`;
const user = await requireAdmin(makeRequest(cookie), env);
expect(user.id).toBe('user_6');
expect(user.role).toBe('admin');
});
});