Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,8 @@ R2_PUBLIC_BASE_PROD="https://oche-media.r2.example"
# --- Cloudflare (CI) ---
CLOUDFLARE_ACCOUNT_ID=""
CLOUDFLARE_API_TOKEN=""

# --- Local dev (SPA + API) ---
VITE_API_BASE="http://localhost:8787"
APP_ORIGIN="http://localhost:5173"
MEDIA_SIGNING_SECRET="dev-media-signing-secret-change-in-prod"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ worker-configuration.d.ts
!.env.example
.dev.vars
.dev.vars.*
!.dev.vars.example
.oche-keys.json
!.oche-keys.example.json
*.pem
*.key
*.p12
Expand Down
1 change: 1 addition & 0 deletions .oche-keys.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

4 changes: 4 additions & 0 deletions apps/api/.dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Wrangler local dev secrets (gitignored).
# Populated automatically: npm run setup:dev-vars:sync (reads root .env)
DATABASE_URL=""
MEDIA_SIGNING_SECRET="dev-media-signing-secret-change-in-prod"
33 changes: 33 additions & 0 deletions apps/api/src/__tests__/media-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import {
MAX_PHOTO_BYTES,
MAX_VIDEO_BYTES,
classifyMedia,
parseRangeHeader,
validateUpload,
} from '../lib/media-policy.js';

describe('media-policy', () => {
it('classifies allowed MIME types', () => {
expect(classifyMedia('image/jpeg')).toBe('photo');
expect(classifyMedia('video/mp4')).toBe('video');
expect(classifyMedia('application/pdf')).toBeNull();
});

it('rejects unsupported or oversized uploads', () => {
expect(validateUpload('image/png', 1024)).toEqual({ ok: true, kind: 'photo' });
expect(validateUpload('video/mp4', MAX_VIDEO_BYTES)).toEqual({ ok: true, kind: 'video' });
expect(validateUpload('video/mp4', MAX_VIDEO_BYTES + 1).ok).toBe(false);
expect(validateUpload('image/jpeg', MAX_PHOTO_BYTES + 1).ok).toBe(false);
expect(validateUpload('text/plain', 10).ok).toBe(false);
expect(validateUpload('image/png', 0).ok).toBe(false);
});

it('parses byte-range headers', () => {
expect(parseRangeHeader(undefined, 1000)).toBeNull();
expect(parseRangeHeader('bytes=0-99', 1000)).toEqual({ offset: 0, length: 100 });
expect(parseRangeHeader('bytes=900-', 1000)).toEqual({ offset: 900, length: 100 });
expect(parseRangeHeader('bytes=-50', 1000)).toEqual({ offset: 950, length: 50 });
expect(parseRangeHeader('bytes=2000-3000', 1000)).toBeNull();
});
});
108 changes: 108 additions & 0 deletions apps/api/src/__tests__/media.routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from 'vitest';
import { createApp } from '../app.js';
import type { Env } from '../env.js';
import { signMediaKey } from '../lib/signed-url.js';

function mockMediaStore() {
const objects = new Map<string, { bytes: ArrayBuffer; contentType: string }>();
return {
put: vi.fn(async (key: string, body: ArrayBuffer, opts: { httpMetadata?: { contentType?: string } }) => {
objects.set(key, {
bytes: body,
contentType: opts.httpMetadata?.contentType ?? 'application/octet-stream',
});
}),
head: vi.fn(async (key: string) => {
const o = objects.get(key);
if (!o) return null;
return { size: o.bytes.byteLength, httpMetadata: { contentType: o.contentType } };
}),
get: vi.fn(async (key: string, opts?: { range?: { offset: number; length: number } }) => {
const o = objects.get(key);
if (!o) return null;
const slice =
opts?.range != null
? o.bytes.slice(opts.range.offset, opts.range.offset + opts.range.length)
: o.bytes;
return { body: slice };
}),
objects,
};
}

function mockEnv(media = mockMediaStore()): Env {
return {
DATABASE_URL: 'postgresql://test',
APP_ORIGIN: 'http://localhost:5173',
ENVIRONMENT: 'development',
MEDIA_SIGNING_SECRET: 'test-secret',
SESSION_ROOM: {
idFromName: () => ({ fetch: vi.fn(async () => new Response('{}')) }),
} as unknown as Env['SESSION_ROOM'],
MEDIA: media as unknown as Env['MEDIA'],
};
}

describe('media routes', () => {
it('POST /media/upload requires auth', async () => {
const app = createApp();
const res = await app.request(
'/media/upload',
{ method: 'POST', headers: { 'content-type': 'image/png' }, body: new Uint8Array([1, 2, 3]) },
mockEnv(),
);
expect(res.status).toBe(401);
});

it('POST /media/upload stores immutable key', async () => {
const store = mockMediaStore();
const app = createApp();
const bytes = new Uint8Array([137, 80, 78, 71]);
const res = await app.request(
'/media/upload',
{
method: 'POST',
headers: { 'content-type': 'image/png', 'x-oche-owner': 'demo-key-a' },
body: bytes,
},
mockEnv(store),
);
expect(res.status).toBe(201);
const body = (await res.json()) as { key: string; kind: string; url: string };
expect(body.kind).toBe('photo');
expect(body.key).toMatch(/^photos\/[a-f0-9]{64}\.png$/);
expect(body.url).toContain(body.key);
expect(store.put).toHaveBeenCalledOnce();
});

it('GET /media/:key serves bytes with range support', async () => {
const store = mockMediaStore();
const key = 'videos/demo.mp4';
const payload = new Uint8Array(100).map((_, i) => i);
store.objects.set(key, { bytes: payload.buffer, contentType: 'video/mp4' });

const { exp, sig } = await signMediaKey({ MEDIA_SIGNING_SECRET: 'test-secret' }, key);
const app = createApp();

const full = await app.request(`/media/${key}?exp=${exp}&sig=${sig}`, {}, mockEnv(store));
expect(full.status).toBe(200);
expect(full.headers.get('Accept-Ranges')).toBe('bytes');
expect(Number(full.headers.get('Content-Length'))).toBe(100);

const partial = await app.request(
`/media/${key}?exp=${exp}&sig=${sig}`,
{ headers: { Range: 'bytes=0-9' } },
mockEnv(store),
);
expect(partial.status).toBe(206);
expect(partial.headers.get('Content-Range')).toBe('bytes 0-9/100');
const chunk = new Uint8Array(await partial.arrayBuffer());
expect(chunk).toEqual(payload.slice(0, 10));
});

it('GET /media/:key rejects bad signatures', async () => {
const app = createApp();
const res = await app.request('/media/videos/x.mp4?exp=9999999999&sig=bad', {}, mockEnv());
expect(res.status).toBe(403);
});
});
14 changes: 14 additions & 0 deletions apps/api/src/__tests__/pagination.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { decodeCursor, encodeCursor } from '../lib/pagination.js';

describe('pagination cursor', () => {
it('round-trips a cursor', () => {
const cursor = { createdAt: '2026-01-01T12:00:00.000Z', id: '11111111-1111-1111-1111-111111111111' };
const encoded = encodeCursor(cursor);
expect(decodeCursor(encoded)).toEqual(cursor);
});

it('returns null for invalid cursor', () => {
expect(decodeCursor('not-valid')).toBeNull();
});
});
34 changes: 34 additions & 0 deletions apps/api/src/__tests__/principal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { Hono } from 'hono';
import type { Env } from '../env.js';
import { resolvePrincipal } from '../principal.js';

function appWithEnv(env: Partial<Env>) {
const app = new Hono<{ Bindings: Env }>();
app.get('/', (c) => c.json({ ownerId: resolvePrincipal(c) }));
return { app, env: env as Env };
}

describe('resolvePrincipal', () => {
it('resolves built-in demo keys', async () => {
const { app, env } = appWithEnv({} as Env);
const res = await app.request('/', { headers: { 'x-oche-owner': 'demo-key-a' } }, env);
expect(await res.json()).toEqual({ ownerId: '11111111-1111-1111-1111-111111111111' });
});

it('merges OCHE_API_KEYS from env', async () => {
const customKey = 'oche_testkey123';
const ownerId = '33333333-3333-3333-3333-333333333333';
const { app, env } = appWithEnv({
OCHE_API_KEYS: JSON.stringify({ [customKey]: ownerId }),
} as Env);
const res = await app.request('/', { headers: { 'x-oche-owner': customKey } }, env);
expect(await res.json()).toEqual({ ownerId });
});

it('returns null for unknown keys', async () => {
const { app, env } = appWithEnv({} as Env);
const res = await app.request('/', { headers: { 'x-oche-owner': 'invalid' } }, env);
expect(await res.json()).toEqual({ ownerId: null });
});
});
24 changes: 24 additions & 0 deletions apps/api/src/__tests__/scores.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { applyScoreChange, SCORE_MAX, SCORE_MIN } from '../lib/scores.js';

describe('applyScoreChange', () => {
it('applies a positive delta', () => {
expect(applyScoreChange(40, { delta: 20 })).toEqual({ ok: true, next: 60, delta: 20 });
});

it('rejects scores below minimum', () => {
const r = applyScoreChange(5, { delta: -10 });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain(String(SCORE_MIN));
});

it('rejects scores above maximum', () => {
const r = applyScoreChange(SCORE_MAX, { delta: 1 });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain(String(SCORE_MAX));
});

it('honours absolute set', () => {
expect(applyScoreChange(40, { set: 100 })).toEqual({ ok: true, next: 100, delta: 60 });
});
});
35 changes: 35 additions & 0 deletions apps/api/src/__tests__/session-room.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import type { WsServerMessage } from '@oche/shared';
import { SessionRoom } from '../session-room.js';

describe('SessionRoom', () => {
it('accepts POST /broadcast and fans out to websockets', async () => {
const sent: string[] = [];
const ws = { send: (msg: string) => sent.push(msg) } as unknown as WebSocket;
const ctx = {
getWebSockets: () => [ws],
acceptWebSocket: () => {},
storage: { setAlarm: async () => {}, deleteAlarm: async () => {} },
};

const room = new SessionRoom(ctx as never, {} as never);
const message: WsServerMessage = {
type: 'score',
playerId: '11111111-1111-1111-1111-111111111111',
newScore: 50,
delta: 10,
};

const res = await room.fetch(
new Request('https://session-room/broadcast', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(message),
}),
);

expect(res.status).toBe(200);
expect(sent).toHaveLength(1);
expect(JSON.parse(sent[0]!)).toEqual(message);
});
});
Loading
Loading