mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-08 21:05:48 +10:00
feat: redesign nav — home shows public links, user menu with dropdown
- Remove Public Directory tab; public links now show on home page - Rename Private Links to My Links - Add right-aligned user menu with hover dropdown (Profile, Settings, Admin, Logout) - Show admin badge next to username when role=admin - Show Sign In button when not logged in - Admin tab only visible to admins - New GET /api/auth/me endpoint returns current user from session - useCurrentUser hook for auth state in React - CSS-only hover dropdown with fade-in animation - Placeholder pages for Profile and Settings
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
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 };
|
||||
expect(data).toEqual({ user: null });
|
||||
// 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 };
|
||||
expect(data).toEqual({ user: null });
|
||||
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;
|
||||
};
|
||||
expect(data.user).toEqual({
|
||||
id: 'user_1',
|
||||
email: 'admin@heygo.cc',
|
||||
name: 'Admin',
|
||||
imageUrl: 'https://example.com/avatar.png',
|
||||
role: 'admin',
|
||||
});
|
||||
// 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 };
|
||||
expect(data.user?.role).toBe('user');
|
||||
});
|
||||
|
||||
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 };
|
||||
expect(data).toEqual({ user: null });
|
||||
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 };
|
||||
expect(data).toEqual({ user: null });
|
||||
});
|
||||
|
||||
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('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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user