Files
heygo/tests/dev-auth.test.ts
Hermes Agent fd647bcc2e feat: add dev-only login for local admin access without OAuth
- POST /api/auth/dev-login creates/finds user, promotes to admin, sets session cookie
- POST /api/auth/dev-logout clears session cookie
- Dev login page at #/dev-login in the SPA
- Fix: getSessionCookieAttributes omits Secure over HTTP (local dev fix)
- Guarded: only works when PUBLIC_HOST=localhost, returns 403 otherwise
- Justfile: dev-admin and dev-logout commands
- README: local admin login instructions
2026-06-20 14:55:28 +10:00

425 lines
12 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import worker from '../worker/index';
import { AUTH_SESSION_COOKIE_NAME, hashSessionToken } from '../worker/auth';
import type { Env } from '../worker/env';
type UserRole = 'user' | 'admin';
type UserRow = {
id: string;
email: string;
name: string | null;
role: UserRole;
};
type SessionRow = {
id: string;
user_id: string;
session_token_hash: string;
expires_at: string;
};
type RunCall = {
sql: string;
params: unknown[];
};
type FirstCall = {
sql: string;
params: unknown[];
};
class FakeDevAuthD1 {
readonly users: UserRow[] = [];
readonly sessions: SessionRow[] = [];
readonly runCalls: RunCall[] = [];
readonly firstCalls: FirstCall[] = [];
prepare(sql: string): FakeDevAuthStatement {
return new FakeDevAuthStatement(this, sql);
}
findUserByEmail(email: string): UserRow | null {
return this.users.find((u) => u.email === email) ?? null;
}
findSessionByHash(hash: string): SessionRow | null {
return this.sessions.find((s) => s.session_token_hash === hash) ?? null;
}
}
class FakeDevAuthStatement {
private params: unknown[] = [];
constructor(
private readonly db: FakeDevAuthD1,
private readonly sql: string,
) {}
bind(...params: unknown[]): this {
this.params = params;
return this;
}
async first<T>(): Promise<T | null> {
this.db.firstCalls.push({ sql: this.sql, params: this.params });
if (this.sql.includes('FROM users') && this.sql.includes('email = ?')) {
const row = this.db.findUserByEmail(String(this.params[0]));
if (!row) {
return null;
}
return { id: row.id, email: row.email, name: row.name, role: row.role } as T;
}
return null;
}
async run(): Promise<D1Result> {
this.db.runCalls.push({ sql: this.sql, params: this.params });
if (this.sql.startsWith('INSERT INTO users')) {
const [id, email, name, role] = this.params;
this.db.users.push({
id: String(id),
email: String(email),
name: name == null ? null : String(name),
role: String(role) as UserRole,
});
}
if (this.sql.startsWith('UPDATE users SET role')) {
const [role, id] = this.params;
const row = this.db.users.find((u) => u.id === String(id));
if (row) {
row.role = String(role) as UserRole;
}
}
if (this.sql.startsWith('INSERT INTO sessions')) {
const [id, userId, tokenHash, expiresAt] = this.params;
this.db.sessions.push({
id: String(id),
user_id: String(userId),
session_token_hash: String(tokenHash),
expires_at: String(expiresAt),
});
}
return { success: true, meta: { changes: 1 } } as unknown as D1Result;
}
}
class FakeExecutionContext {
waitUntil(): void {}
passThroughOnException(): void {}
}
type DevLoginResponse = {
ok: boolean;
user: { id: string; email: string; name: string | null; role: string };
};
type DevErrorResponse = { error: string };
type EnvOverrides = Partial<Env>;
function makeEnv(db: FakeDevAuthD1, overrides: EnvOverrides = {}): Env {
return {
DB: db as unknown as D1Database,
PUBLIC_HOST: 'localhost',
PRIVATE_HOST: 'localhost',
APP_BASE_URL: 'http://localhost:8787',
COOKIE_DOMAIN: '',
...overrides,
} as Env;
}
function prodEnv(overrides: EnvOverrides = {}): Env {
return {
DB: {} as D1Database,
PUBLIC_HOST: 'heygo.cc',
PRIVATE_HOST: 'my.heygo.cc',
APP_BASE_URL: 'https://heygo.cc',
COOKIE_DOMAIN: '.heygo.cc',
...overrides,
} as Env;
}
async function fetchWorker(
path: string,
env: Env,
init: RequestInit = {},
): Promise<Response> {
const url = `http://localhost:8787${path}`;
const request = new Request(url, init);
const ctx = new FakeExecutionContext();
return worker.fetch(
request as unknown as Parameters<typeof worker.fetch>[0],
env as unknown as Parameters<typeof worker.fetch>[1],
ctx as unknown as Parameters<typeof worker.fetch>[2],
);
}
function jsonPost(path: string, body: unknown): RequestInit {
return {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
};
}
describe('dev auth endpoint', () => {
it('creates a new admin user and sets a session cookie on POST /api/auth/dev-login', async () => {
const db = new FakeDevAuthD1();
const env = makeEnv(db);
const response = await fetchWorker(
'/api/auth/dev-login',
env,
jsonPost('/api/auth/dev-login', { email: 'wahyd4@gmail.com' }),
);
expect(response.status).toBe(200);
const setCookie = response.headers.get('set-cookie');
expect(setCookie).not.toBeNull();
expect(setCookie).toContain(`${AUTH_SESSION_COOKIE_NAME}=`);
expect(setCookie).toContain('HttpOnly');
expect(setCookie).toContain('Path=/');
// Local dev is HTTP, so Secure must NOT be present
expect(setCookie).not.toContain('Secure');
const data = (await response.json()) as DevLoginResponse;
expect(data).toMatchObject({
ok: true,
user: { email: 'wahyd4@gmail.com', role: 'admin' },
});
// A user was created with admin role
expect(db.users).toHaveLength(1);
expect(db.users[0].email).toBe('wahyd4@gmail.com');
expect(db.users[0].role).toBe('admin');
// A session was created
expect(db.sessions).toHaveLength(1);
const createdSession = db.sessions[0];
expect(createdSession.user_id).toBe(db.users[0].id);
expect(createdSession.session_token_hash).not.toBe('');
// expires_at is a valid future ISO date
const expiresAt = Date.parse(createdSession.expires_at);
expect(Number.isNaN(expiresAt)).toBe(false);
expect(expiresAt).toBeGreaterThan(Date.now());
});
it('reuses an existing user and promotes a non-admin to admin', async () => {
const db = new FakeDevAuthD1();
db.users.push({
id: 'user_existing',
email: 'wahyd4@gmail.com',
name: 'wahyd4',
role: 'user',
});
const env = makeEnv(db);
const response = await fetchWorker(
'/api/auth/dev-login',
env,
jsonPost('/api/auth/dev-login', { email: 'wahyd4@gmail.com' }),
);
expect(response.status).toBe(200);
const data = (await response.json()) as DevLoginResponse;
expect(data.user.role).toBe('admin');
expect(data.user.id).toBe('user_existing');
// No new user inserted
expect(db.users).toHaveLength(1);
// Role promoted to admin via UPDATE
expect(db.users[0].role).toBe('admin');
const updateCall = db.runCalls.find((c) => c.sql.startsWith('UPDATE users SET role'));
expect(updateCall).toBeDefined();
expect(updateCall?.params).toEqual(['admin', 'user_existing']);
// Session was still created
expect(db.sessions).toHaveLength(1);
});
it('does not issue an UPDATE when the existing user is already admin', async () => {
const db = new FakeDevAuthD1();
db.users.push({
id: 'user_admin',
email: 'wahyd4@gmail.com',
name: 'wahyd4',
role: 'admin',
});
const env = makeEnv(db);
const response = await fetchWorker(
'/api/auth/dev-login',
env,
jsonPost('/api/auth/dev-login', { email: 'wahyd4@gmail.com' }),
);
expect(response.status).toBe(200);
expect(db.runCalls.find((c) => c.sql.startsWith('UPDATE users SET role'))).toBeUndefined();
expect(db.sessions).toHaveLength(1);
});
it('rejects GET /api/auth/dev-login with 405', async () => {
const db = new FakeDevAuthD1();
const env = makeEnv(db);
const response = await fetchWorker('/api/auth/dev-login', env, { method: 'GET' });
expect(response.status).toBe(405);
const data = (await response.json()) as DevErrorResponse;
expect(data.error).toMatch(/method/i);
// No DB writes happened
expect(db.runCalls).toHaveLength(0);
});
it('rejects an invalid email with 400', async () => {
const db = new FakeDevAuthD1();
const env = makeEnv(db);
const response = await fetchWorker(
'/api/auth/dev-login',
env,
jsonPost('/api/auth/dev-login', { email: 'not-an-email' }),
);
expect(response.status).toBe(400);
const data = (await response.json()) as DevErrorResponse;
expect(data.error).toMatch(/email/i);
expect(db.users).toHaveLength(0);
expect(db.sessions).toHaveLength(0);
});
it('rejects a missing email with 400', async () => {
const db = new FakeDevAuthD1();
const env = makeEnv(db);
const response = await fetchWorker(
'/api/auth/dev-login',
env,
jsonPost('/api/auth/dev-login', {}),
);
expect(response.status).toBe(400);
expect(db.users).toHaveLength(0);
});
it('rejects an invalid JSON body with 400', async () => {
const db = new FakeDevAuthD1();
const env = makeEnv(db);
const response = await fetchWorker('/api/auth/dev-login', env, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: 'not-json',
});
expect(response.status).toBe(400);
const data = (await response.json()) as DevErrorResponse;
expect(data.error).toMatch(/json/i);
});
it('is guarded: returns 403 when PUBLIC_HOST is not localhost', async () => {
const db = new FakeDevAuthD1();
const env = prodEnv({ DB: db as unknown as D1Database });
const response = await fetchWorker(
'/api/auth/dev-login',
env,
jsonPost('/api/auth/dev-login', { email: 'wahyd4@gmail.com' }),
);
expect(response.status).toBe(403);
const data = (await response.json()) as DevErrorResponse;
expect(data.error).toMatch(/local/i);
// No user or session created
expect(db.users).toHaveLength(0);
expect(db.sessions).toHaveLength(0);
});
it('is guarded: returns 403 even when APP_BASE_URL has no localhost', async () => {
const db = new FakeDevAuthD1();
const env = prodEnv({ DB: db as unknown as D1Database, PUBLIC_HOST: 'dev.heygo.cc' });
const response = await fetchWorker(
'/api/auth/dev-login',
env,
jsonPost('/api/auth/dev-login', { email: 'wahyd4@gmail.com' }),
);
expect(response.status).toBe(403);
expect(db.users).toHaveLength(0);
});
it('POST /api/auth/dev-logout clears the session cookie with a past expiry', async () => {
const db = new FakeDevAuthD1();
const env = makeEnv(db);
const response = await fetchWorker(
'/api/auth/dev-logout',
env,
{ method: 'POST' },
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ ok: true });
const setCookie = response.headers.get('set-cookie');
expect(setCookie).not.toBeNull();
expect(setCookie).toContain(`${AUTH_SESSION_COOKIE_NAME}=;`);
// Must clear the cookie via an expiry in the past
expect(setCookie).toContain('Expires=');
// Should NOT contain Secure (HTTP local dev)
expect(setCookie).not.toContain('Secure');
});
it('dev-logout is guarded: returns 403 when not local dev', async () => {
const env = prodEnv();
const response = await fetchWorker('/api/auth/dev-logout', env, { method: 'POST' });
expect(response.status).toBe(403);
});
it('returns a valid session that getCurrentUser can later resolve', async () => {
const db = new FakeDevAuthD1();
const env = makeEnv(db);
const response = await fetchWorker(
'/api/auth/dev-login',
env,
jsonPost('/api/auth/dev-login', { email: 'wahyd4@gmail.com' }),
);
expect(response.status).toBe(200);
const setCookie = response.headers.get('set-cookie');
expect(setCookie).not.toBeNull();
// Extract the raw token value from the Set-Cookie header
const tokenMatch = setCookie!.match(new RegExp(`${AUTH_SESSION_COOKIE_NAME}=([^;]+)`));
expect(tokenMatch).not.toBeNull();
const token = tokenMatch![1];
// The stored hash must match a SHA-256 of the token we issued
const expectedHash = await hashSessionToken(token);
const session = db.findSessionByHash(expectedHash);
expect(session).not.toBeNull();
expect(session?.user_id).toBe(db.users[0].id);
});
it('falls through (404) for unrelated /api/ paths', async () => {
const db = new FakeDevAuthD1();
const env = makeEnv(db);
const response = await fetchWorker('/api/some-other-path', env, { method: 'GET' });
expect(response.status).toBe(404);
// No DB writes for dev auth
expect(db.runCalls).toHaveLength(0);
});
});