mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-08 21:05:48 +10:00
- 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
425 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|