mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-09 05:15:52 +10:00
349 lines
10 KiB
TypeScript
349 lines
10 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 SessionUserRow = {
|
|
id: string;
|
|
email: string | null;
|
|
name: string | null;
|
|
image_url: string | null;
|
|
role: UserRole;
|
|
expires_at: string;
|
|
session_token_hash: string;
|
|
};
|
|
|
|
type FirstCall = {
|
|
sql: string;
|
|
params: unknown[];
|
|
};
|
|
|
|
class FakeAuthD1 {
|
|
readonly rows: SessionUserRow[] = [];
|
|
readonly firstCalls: FirstCall[] = [];
|
|
|
|
prepare(sql: string): FakeAuthStatement {
|
|
return new FakeAuthStatement(this, sql);
|
|
}
|
|
|
|
findSessionByHash(hash: string): SessionUserRow | null {
|
|
return this.rows.find((r) => r.session_token_hash === hash) ?? null;
|
|
}
|
|
}
|
|
|
|
class FakeAuthStatement {
|
|
private params: unknown[] = [];
|
|
|
|
constructor(
|
|
private readonly db: FakeAuthD1,
|
|
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 });
|
|
|
|
// The getCurrentUser session lookup: SELECT users ... JOIN sessions
|
|
if (this.sql.includes('FROM sessions') && this.sql.includes('JOIN users')) {
|
|
const hash = String(this.params[0]);
|
|
const row = this.db.findSessionByHash(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;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
class FakeExecutionContext {
|
|
waitUntil(): void {}
|
|
passThroughOnException(): void {}
|
|
}
|
|
|
|
type EnvOverrides = Partial<Env>;
|
|
|
|
function makeEnv(db: FakeAuthD1, 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;
|
|
}
|
|
|
|
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 futureIso(): string {
|
|
return new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
|
}
|
|
|
|
function cookieHeader(token: string): string {
|
|
return `${AUTH_SESSION_COOKIE_NAME}=${token}`;
|
|
}
|
|
|
|
describe('GET /api/auth/me', () => {
|
|
it('returns { user: null } with 200 when no Cookie header is present', async () => {
|
|
const db = new FakeAuthD1();
|
|
const env = makeEnv(db);
|
|
|
|
const response = await fetchWorker('/api/auth/me', env, { method: 'GET' });
|
|
|
|
expect(response.status).toBe(200);
|
|
const data = (await response.json()) as { user: unknown; publicBaseUrl: string; privateBaseUrl: string };
|
|
expect(data).toEqual({
|
|
user: null,
|
|
publicBaseUrl: 'http://localhost:8787',
|
|
privateBaseUrl: 'http://localhost:8787',
|
|
});
|
|
// No session lookup should have run when there's no cookie
|
|
expect(db.firstCalls).toHaveLength(0);
|
|
});
|
|
|
|
it('returns { user: null } with 200 when the session cookie is missing', async () => {
|
|
const db = new FakeAuthD1();
|
|
const env = makeEnv(db);
|
|
|
|
const response = await fetchWorker('/api/auth/me', env, {
|
|
method: 'GET',
|
|
headers: { cookie: 'other_cookie=abc' },
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
const data = (await response.json()) as { user: unknown; publicBaseUrl: string; privateBaseUrl: string };
|
|
expect(data).toEqual({
|
|
user: null,
|
|
publicBaseUrl: 'http://localhost:8787',
|
|
privateBaseUrl: 'http://localhost:8787',
|
|
});
|
|
expect(db.firstCalls).toHaveLength(0);
|
|
});
|
|
|
|
it('returns the user payload when a valid session cookie exists', async () => {
|
|
const token = 'valid-me-token';
|
|
const hash = await hashSessionToken(token);
|
|
const db = new FakeAuthD1();
|
|
db.rows.push({
|
|
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 env = makeEnv(db);
|
|
|
|
const response = await fetchWorker('/api/auth/me', env, {
|
|
method: 'GET',
|
|
headers: { cookie: cookieHeader(token) },
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
const data = (await response.json()) as {
|
|
user: {
|
|
id: string;
|
|
email: string | null;
|
|
name: string | null;
|
|
imageUrl: string | null;
|
|
role: 'user' | 'admin';
|
|
} | null;
|
|
publicBaseUrl: string;
|
|
privateBaseUrl: string;
|
|
};
|
|
expect(data.user).toEqual({
|
|
id: 'user_1',
|
|
email: 'admin@heygo.cc',
|
|
name: 'Admin',
|
|
imageUrl: 'https://example.com/avatar.png',
|
|
role: 'admin',
|
|
});
|
|
expect(data.publicBaseUrl).toBe('http://localhost:8787');
|
|
expect(data.privateBaseUrl).toBe('http://localhost:8787');
|
|
// One session lookup was performed
|
|
expect(db.firstCalls).toHaveLength(1);
|
|
expect(db.firstCalls[0].sql).toContain('FROM sessions');
|
|
expect(db.firstCalls[0].sql).toContain('JOIN users');
|
|
});
|
|
|
|
it('returns the user payload for a non-admin user', async () => {
|
|
const token = 'normal-me-token';
|
|
const hash = await hashSessionToken(token);
|
|
const db = new FakeAuthD1();
|
|
db.rows.push({
|
|
id: 'user_2',
|
|
email: 'person@heygo.cc',
|
|
name: 'Person',
|
|
image_url: null,
|
|
role: 'user',
|
|
expires_at: futureIso(),
|
|
session_token_hash: hash,
|
|
});
|
|
const env = makeEnv(db);
|
|
|
|
const response = await fetchWorker('/api/auth/me', env, {
|
|
method: 'GET',
|
|
headers: { cookie: cookieHeader(token) },
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
const data = (await response.json()) as { user: { role: string } | null; publicBaseUrl: string; privateBaseUrl: string };
|
|
expect(data.user?.role).toBe('user');
|
|
expect(data.publicBaseUrl).toBe('http://localhost:8787');
|
|
expect(data.privateBaseUrl).toBe('http://localhost:8787');
|
|
});
|
|
|
|
it('returns { user: null } when the session token has no matching row', async () => {
|
|
const db = new FakeAuthD1();
|
|
const env = makeEnv(db);
|
|
|
|
const response = await fetchWorker('/api/auth/me', env, {
|
|
method: 'GET',
|
|
headers: { cookie: cookieHeader('unknown-token') },
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
const data = (await response.json()) as { user: unknown; publicBaseUrl: string; privateBaseUrl: string };
|
|
expect(data).toEqual({
|
|
user: null,
|
|
publicBaseUrl: 'http://localhost:8787',
|
|
privateBaseUrl: 'http://localhost:8787',
|
|
});
|
|
expect(db.firstCalls).toHaveLength(1);
|
|
});
|
|
|
|
it('returns { user: null } for an expired session', async () => {
|
|
const token = 'expired-me-token';
|
|
const hash = await hashSessionToken(token);
|
|
const db = new FakeAuthD1();
|
|
db.rows.push({
|
|
id: 'user_3',
|
|
email: 'expired@heygo.cc',
|
|
name: 'Expired',
|
|
image_url: null,
|
|
role: 'user',
|
|
expires_at: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
|
session_token_hash: hash,
|
|
});
|
|
const env = makeEnv(db);
|
|
|
|
const response = await fetchWorker('/api/auth/me', env, {
|
|
method: 'GET',
|
|
headers: { cookie: cookieHeader(token) },
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
const data = (await response.json()) as { user: unknown; publicBaseUrl: string; privateBaseUrl: string };
|
|
expect(data).toEqual({
|
|
user: null,
|
|
publicBaseUrl: 'http://localhost:8787',
|
|
privateBaseUrl: 'http://localhost:8787',
|
|
});
|
|
});
|
|
|
|
it('rejects non-GET methods with 405', async () => {
|
|
const db = new FakeAuthD1();
|
|
const env = makeEnv(db);
|
|
|
|
const response = await fetchWorker('/api/auth/me', env, { method: 'POST' });
|
|
|
|
expect(response.status).toBe(405);
|
|
const data = (await response.json()) as { error: string };
|
|
expect(data.error).toMatch(/method/i);
|
|
// No session lookup should have run
|
|
expect(db.firstCalls).toHaveLength(0);
|
|
});
|
|
|
|
it('POST /api/auth/logout clears the shared session cookie outside local dev', async () => {
|
|
const db = new FakeAuthD1();
|
|
const env = makeEnv(db, {
|
|
PUBLIC_HOST: 'dev.heygo.cc',
|
|
PRIVATE_HOST: 'my.dev.heygo.cc',
|
|
APP_BASE_URL: 'https://dev.heygo.cc',
|
|
COOKIE_DOMAIN: '.heygo.cc',
|
|
});
|
|
|
|
const response = await fetchWorker('/api/auth/logout', env, { method: 'POST' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({ ok: true });
|
|
const setCookie = response.headers.get('set-cookie');
|
|
expect(setCookie).toContain(`${AUTH_SESSION_COOKIE_NAME}=;`);
|
|
expect(setCookie).toContain('Expires=Thu, 01 Jan 1970 00:00:00 GMT');
|
|
expect(setCookie).toContain('Domain=.heygo.cc');
|
|
expect(setCookie).toContain('HttpOnly');
|
|
expect(response.headers.get('cache-control')).toBe('no-store');
|
|
});
|
|
|
|
it('rejects GET /api/auth/logout with 405', async () => {
|
|
const db = new FakeAuthD1();
|
|
const env = makeEnv(db);
|
|
|
|
const response = await fetchWorker('/api/auth/logout', env, { method: 'GET' });
|
|
|
|
expect(response.status).toBe(405);
|
|
});
|
|
|
|
it('falls through (returns null) for non-matching paths so the worker 404s', async () => {
|
|
const db = new FakeAuthD1();
|
|
const env = makeEnv(db);
|
|
|
|
// A path that starts with /api/ but is not /api/auth/me should fall through
|
|
// to the other handlers and ultimately the worker's 404 fallback.
|
|
const response = await fetchWorker('/api/some-other-endpoint', env, { method: 'GET' });
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(db.firstCalls).toHaveLength(0);
|
|
});
|
|
|
|
it('applies private-host no-store headers on private host', async () => {
|
|
const db = new FakeAuthD1();
|
|
// Use a private host so withPrivateHostNoStoreHeaders adds no-store
|
|
const env = makeEnv(db, {
|
|
PUBLIC_HOST: 'heygo.cc',
|
|
PRIVATE_HOST: 'my.heygo.cc',
|
|
});
|
|
|
|
const url = 'https://my.heygo.cc/api/auth/me';
|
|
const request = new Request(url, { method: 'GET' });
|
|
const ctx = new FakeExecutionContext();
|
|
const response = await 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],
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get('cache-control')).toContain('no-store');
|
|
});
|
|
});
|