19dd30acbe
The preview cards led with a generated gradient. It was a deliberate fallback — nothing renders a frame of a Cap embed without loading the embed, and loading nine embeds to decorate a grid is how a page becomes unusable on a phone — but for videos PIG serves itself the frame is right there in the file. The poster is named after the VIDEO's content hash, not its own: `overview.4d4581ae.mp4` -> `overview.4d4581ae.jpg`. Re-rendering a clip changes both names together, so a thumbnail cannot outlive what it claims to show. It needs no schema column and no manifest entry, because the name is derivable. `learnPoster.sh` cuts the frame with `thumbnail=90` starting four seconds in rather than taking frame 0: the first frame of a Playwright capture is often mid-paint, and a poster of a half-rendered page is worse than no poster. The resolver ASSERTS the poster rather than verifying it — @pig/core is pure and has no filesystem. That is safe in both directions: a missing poster 404s, which `<video poster>` renders exactly as it renders no poster, and which the card falls back from via onError. Claiming a poster that is absent is free; omitting one that exists would cost every card its thumbnail. Cap-hosted rows are unchanged and still get the gradient, verified by there being exactly five <img> elements on a page with nine resources. Also widens the media allowlist to jpg/webp. The filename pattern, the traversal rules and the symlink check are untouched and still cover them, because extension is the only axis that changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
601 lines
25 KiB
TypeScript
601 lines
25 KiB
TypeScript
/**
|
||
* Tests for the Learn boundary.
|
||
*
|
||
* The one that matters is `learn token is not a credential for anything else`.
|
||
* Every other assertion here is supporting evidence for it: the design's whole
|
||
* claim is that a code-holder cannot become a principal, and the way that
|
||
* claim fails in practice is not a dramatic bug — it is somebody later
|
||
* deciding it would be simpler to mint a `Principal` with an empty team list
|
||
* and rely on capability checks downstream. That refactor passes every test
|
||
* about learn resources and fails this one.
|
||
*
|
||
* The rest pin decisions that would otherwise fail silently: an embed resolver
|
||
* that accepts a hostile host, a PATCH that promotes a supply video to
|
||
* anon-visible because it validated the input instead of the merged row, and
|
||
* a rate limiter whose window never closes.
|
||
*/
|
||
import { strict as assert } from 'node:assert';
|
||
import { describe, it } from 'node:test';
|
||
import {
|
||
LEARN_CODE_TRACK,
|
||
LEARN_FRAME_SRC_HOSTS,
|
||
formatLearnDuration,
|
||
learnEmbed,
|
||
learnEmbedUrl,
|
||
learnVisibilityPermitted,
|
||
resolveLearnEmbed,
|
||
} from '@pig/core';
|
||
import type { Database } from '@pig/db';
|
||
import { learnResources, platformSettings } from '@pig/db';
|
||
import { createMediaRoutes, LEARN_MEDIA_DIR_ENV, parseByteRange } from '../src/lib/media';
|
||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { createApp } from '../src/app';
|
||
import { loadConfig } from '../src/lib/config';
|
||
import {
|
||
LEARN_TOKEN_TTL_MS,
|
||
createAttemptLimiter,
|
||
learnResourceCreateSchema,
|
||
mintLearnToken,
|
||
rateLimitKey,
|
||
verifyLearnToken,
|
||
} from '../src/routes/learn';
|
||
|
||
const ACCESS_CODE = 'carlthefog';
|
||
|
||
// ---------------------------------------------------------------- the embed
|
||
|
||
describe('embed allowlist', () => {
|
||
it('resolves a Cap share link to an embed rebuilt from the table', () => {
|
||
const resolved = resolveLearnEmbed('https://video.karti.ai/s/0n6n9p83efnxbs2');
|
||
assert.equal(resolved.ok, true);
|
||
assert.equal(resolved.ok && resolved.provider, 'cap');
|
||
assert.equal(resolved.ok && resolved.externalId, '0n6n9p83efnxbs2');
|
||
assert.equal(resolved.ok && resolved.embedUrl, 'https://video.karti.ai/embed/0n6n9p83efnxbs2');
|
||
});
|
||
|
||
it('accepts an embed link too, because that is what people copy', () => {
|
||
const resolved = resolveLearnEmbed('https://video.karti.ai/embed/0n6n9p83efnxbs2');
|
||
assert.equal(resolved.ok && resolved.watchUrl, 'https://video.karti.ai/s/0n6n9p83efnxbs2');
|
||
});
|
||
|
||
it('refuses every shape that would put someone else’s bytes in an iframe src', () => {
|
||
// Each of these is a real technique, not a hypothetical. The suffix case
|
||
// is why `hosts` is an exact-match list rather than an `endsWith` check,
|
||
// and the credential case is why a URL that READS as trusted to a human is
|
||
// rejected on the parsed hostname instead.
|
||
const hostile = [
|
||
'javascript:alert(1)',
|
||
'data:text/html,<script>alert(1)</script>',
|
||
'http://video.karti.ai/s/0n6n9p83efnxbs2',
|
||
'https://video.karti.ai@evil.example/s/0n6n9p83efnxbs2',
|
||
'https://evil-video.karti.ai.attacker.test/s/0n6n9p83efnxbs2',
|
||
'https://notvideo.karti.ai/s/0n6n9p83efnxbs2',
|
||
'https://video.karti.ai:8443/s/0n6n9p83efnxbs2',
|
||
'https://video.karti.ai/s/../../admin',
|
||
'https://video.karti.ai/s/0n6n9p83efnxbs2/edit',
|
||
'https://video.karti.ai/s/"><script>alert(1)</script>',
|
||
'https://video.karti.ai/',
|
||
'not a url at all',
|
||
];
|
||
for (const candidate of hostile) {
|
||
assert.equal(resolveLearnEmbed(candidate).ok, false, `should reject: ${candidate}`);
|
||
}
|
||
});
|
||
|
||
it('refuses a recognised but not-yet-enabled provider rather than framing it', () => {
|
||
// Loom is in the table so that enabling it is a flag and a CSP host. Until
|
||
// the CSP host exists, a Loom row would be a card that silently never
|
||
// plays — so the row cannot be created at all.
|
||
const resolved = resolveLearnEmbed('https://www.loom.com/share/0123456789abcdef');
|
||
assert.equal(resolved.ok, false);
|
||
assert.equal(resolved.ok === false && resolved.reason, 'provider_disabled');
|
||
});
|
||
|
||
it('re-validates a stored id rather than trusting the database', () => {
|
||
// A row written before the pattern tightened, or by a path that skipped
|
||
// the resolver, must not be framed on the strength of having persisted.
|
||
assert.equal(learnEmbedUrl('cap', '"><iframe src=x'), null);
|
||
assert.equal(learnEmbedUrl('cap', '0n6n9p83efnxbs2'), 'https://video.karti.ai/embed/0n6n9p83efnxbs2');
|
||
});
|
||
|
||
it('names every enabled host, so the CSP handoff cannot drift', () => {
|
||
// The self-hosted provider is enabled and contributes NOTHING here. A
|
||
// native <video> on this origin is covered by `default-src 'self'`, and
|
||
// widening frame-src for it would hand out framing rights nothing needs.
|
||
assert.deepEqual(LEARN_FRAME_SRC_HOSTS, ['https://video.karti.ai']);
|
||
});
|
||
});
|
||
|
||
// ------------------------------------------------------------- self-hosted
|
||
|
||
describe('self-hosted media', () => {
|
||
it('resolves a /media/learn path to a native video, not an iframe', () => {
|
||
const resolved = resolveLearnEmbed('/media/learn/pig-tour.7f3a91c2.mp4');
|
||
assert.equal(resolved.ok, true);
|
||
assert.equal(resolved.ok && resolved.provider, 'pig');
|
||
assert.equal(resolved.ok && resolved.externalId, 'pig-tour.7f3a91c2.mp4');
|
||
assert.deepEqual(resolved.ok && resolved.embed, {
|
||
kind: 'video',
|
||
src: '/media/learn/pig-tour.7f3a91c2.mp4',
|
||
// The poster is derived from the VIDEO's content hash, so a re-render
|
||
// moves both names together and a thumbnail cannot outlive its clip.
|
||
poster: '/media/learn/pig-tour.7f3a91c2.jpg',
|
||
});
|
||
// The flat field stays in step for callers written before the union.
|
||
assert.equal(resolved.ok && resolved.embedUrl, '/media/learn/pig-tour.7f3a91c2.mp4');
|
||
});
|
||
|
||
it('still resolves a remote provider to an iframe', () => {
|
||
const resolved = resolveLearnEmbed('https://video.karti.ai/s/0n6n9p83efnxbs2');
|
||
assert.deepEqual(resolved.ok && resolved.embed, {
|
||
kind: 'iframe',
|
||
src: 'https://video.karti.ai/embed/0n6n9p83efnxbs2',
|
||
});
|
||
});
|
||
|
||
it('refuses every path that would read a file we did not mean to serve', () => {
|
||
// Traversal in each encoding that has ever worked somewhere, a scheme, a
|
||
// host, a protocol-relative URL that a naive `startsWith('/')` would treat
|
||
// as a path, an extension we do not serve, and a bare dotfile.
|
||
const hostile = [
|
||
'/media/learn/../../etc/passwd',
|
||
'/media/learn/..%2f..%2fetc%2fpasswd',
|
||
'/media/learn/../secrets.mp4',
|
||
'/media/learn/..',
|
||
'/media/learn/sub/dir/video.mp4',
|
||
'/media/learn/',
|
||
'/etc/passwd',
|
||
'/media/other/video.mp4',
|
||
'//evil.example/media/learn/video.mp4',
|
||
'file:///media/learn/video.mp4',
|
||
'https://primeintellectgrowth.com/media/learn/pig-tour.7f3a91c2.mp4',
|
||
'/media/learn/.env',
|
||
'/media/learn/video.mp4.sh',
|
||
'/media/learn/video mp4.mp4',
|
||
'/media/learn/video.mp4?v=2',
|
||
'/media/learn/video.mp4#t=10',
|
||
'/media/learn/-leading-dash.mp4',
|
||
`/media/learn/${'a'.repeat(130)}.mp4`,
|
||
];
|
||
for (const candidate of hostile) {
|
||
assert.equal(resolveLearnEmbed(candidate).ok, false, `should reject: ${candidate}`);
|
||
}
|
||
});
|
||
|
||
it('re-validates a stored filename rather than trusting the database', () => {
|
||
// Same argument as the Cap case: persistence is not validation. A row
|
||
// written by a future path that skipped the resolver must not become a
|
||
// file read.
|
||
assert.equal(learnEmbed('pig', '../../etc/passwd'), null);
|
||
assert.equal(learnEmbed('pig', 'pig-tour.mp4.sh'), null);
|
||
assert.deepEqual(learnEmbed('pig', 'pig-tour.7f3a91c2.mp4'), {
|
||
kind: 'video',
|
||
src: '/media/learn/pig-tour.7f3a91c2.mp4',
|
||
// The poster is derived from the VIDEO's content hash, so a re-render
|
||
// moves both names together and a thumbnail cannot outlive its clip.
|
||
poster: '/media/learn/pig-tour.7f3a91c2.jpg',
|
||
});
|
||
});
|
||
|
||
it('accepts a self-hosted path at the create schema, on the platform track', () => {
|
||
const parsed = learnResourceCreateSchema.safeParse({
|
||
track: LEARN_CODE_TRACK,
|
||
title: 'A tour of PIG in five minutes',
|
||
url: '/media/learn/pig-tour.7f3a91c2.mp4',
|
||
visibility: 'code',
|
||
});
|
||
assert.equal(parsed.success, true);
|
||
});
|
||
});
|
||
|
||
// ------------------------------------------------------------- the two rules
|
||
|
||
describe('code visibility', () => {
|
||
it('permits code visibility on the platform track only', () => {
|
||
assert.equal(learnVisibilityPermitted('platform', 'code'), true);
|
||
assert.equal(learnVisibilityPermitted('supply', 'code'), false);
|
||
assert.equal(learnVisibilityPermitted('demand', 'code'), false);
|
||
// Members-only is legal everywhere, including on the platform track.
|
||
for (const track of ['supply', 'demand', 'platform'] as const) {
|
||
assert.equal(learnVisibilityPermitted(track, 'members'), true);
|
||
}
|
||
});
|
||
|
||
it('refuses a code-visible concept resource at the write schema', () => {
|
||
const rejected = learnResourceCreateSchema.safeParse({
|
||
track: 'supply',
|
||
title: 'How capacity is priced',
|
||
url: 'https://video.karti.ai/s/0n6n9p83efnxbs2',
|
||
visibility: 'code',
|
||
});
|
||
assert.equal(rejected.success, false);
|
||
|
||
const accepted = learnResourceCreateSchema.safeParse({
|
||
track: LEARN_CODE_TRACK,
|
||
title: 'Your first hour in PIG',
|
||
url: 'https://video.karti.ai/s/0n6n9p83efnxbs2',
|
||
visibility: 'code',
|
||
});
|
||
assert.equal(accepted.success, true);
|
||
});
|
||
});
|
||
|
||
// ----------------------------------------------------------------- the token
|
||
|
||
describe('learn token', () => {
|
||
it('verifies a token it minted, and refuses one minted under another code', () => {
|
||
const token = mintLearnToken(ACCESS_CODE, Date.now() + LEARN_TOKEN_TTL_MS);
|
||
assert.equal(verifyLearnToken(ACCESS_CODE, token).valid, true);
|
||
|
||
// Rotation is total precisely because the signing key is derived from the
|
||
// code — there is no revocation list to forget to write to.
|
||
const afterRotation = verifyLearnToken('anothercode', token);
|
||
assert.equal(afterRotation.valid, false);
|
||
assert.equal(afterRotation.valid === false && afterRotation.reason, 'mismatch');
|
||
});
|
||
|
||
it('refuses an expired token, a forged signature and a rewritten expiry', () => {
|
||
const expiry = Date.now() + LEARN_TOKEN_TTL_MS;
|
||
const token = mintLearnToken(ACCESS_CODE, expiry);
|
||
|
||
assert.equal(verifyLearnToken(ACCESS_CODE, token, expiry + 1).valid, false);
|
||
assert.equal(verifyLearnToken(ACCESS_CODE, `${token}x`).valid, false);
|
||
assert.equal(verifyLearnToken(ACCESS_CODE, 'learn_v1.99999999999999.aaaa').valid, false);
|
||
// The expiry is signed, so extending it invalidates the token rather than
|
||
// extending the session.
|
||
const [, , signature] = token.slice('learn_'.length).split('.');
|
||
assert.equal(verifyLearnToken(ACCESS_CODE, `learn_v1.${expiry + 60_000}.${signature}`).valid, false);
|
||
assert.equal(verifyLearnToken(ACCESS_CODE, undefined).valid, false);
|
||
assert.equal(verifyLearnToken(null, token).valid, false);
|
||
});
|
||
});
|
||
|
||
// ----------------------------------------------------------- the whole point
|
||
|
||
/**
|
||
* A learn token must be worthless everywhere except one handler.
|
||
*
|
||
* This runs against the real `createApp`, not a stub, because the property
|
||
* being asserted is about composition: what the authenticator does with a
|
||
* bearer token it does not recognise, on routes this feature never mentions.
|
||
* A fake would assert my own assumptions back at me.
|
||
*
|
||
* No database is touched — every path here fails in the auth middleware,
|
||
* before a handler runs — so the stub below is a placeholder that would throw
|
||
* loudly if anything ever reached it. That is deliberate: if a future change
|
||
* lets a learn token past the middleware, this test fails with a database
|
||
* error rather than passing quietly.
|
||
*/
|
||
describe('a learn token is not a credential for anything else', () => {
|
||
const config = loadConfig({
|
||
NODE_ENV: 'production',
|
||
DATABASE_URL: 'postgres://unused:unused@127.0.0.1:1/unused',
|
||
PIG_PUBLIC_URL: 'https://pig-learn-test.invalid',
|
||
SUPABASE_URL: 'https://identity-learn-test.invalid',
|
||
SUPABASE_ANON_KEY: 'learn-test-anon-key',
|
||
SUPABASE_SERVICE_KEY: '',
|
||
PIG_ADMIN_EMAILS: '',
|
||
PIGGY_ENABLED: 'false',
|
||
});
|
||
|
||
const db = new Proxy(
|
||
{},
|
||
{
|
||
get() {
|
||
throw new Error('A learn token reached the database. It must never resolve a principal.');
|
||
},
|
||
},
|
||
) as unknown as Database;
|
||
|
||
const authProvider = {
|
||
name: 'learn-test-stub',
|
||
async verifyAccessToken(): Promise<{ subject: string; email: string }> {
|
||
// A learn token is not a JWT. If this is ever called with one, the
|
||
// authenticator has started treating it as an identity assertion.
|
||
throw new Error('Not a valid identity token.');
|
||
},
|
||
};
|
||
|
||
const app = createApp(config, db, authProvider);
|
||
const token = mintLearnToken(ACCESS_CODE, Date.now() + LEARN_TOKEN_TTL_MS);
|
||
|
||
// The routes a leak would be worth having. `/api/dashboard` is the one
|
||
// scripts/deploy.sh probes before it will finish a release.
|
||
for (const path of ['/api/dashboard', '/api/accounts', '/api/contracts']) {
|
||
it(`answers 401 on ${path} for a valid learn token`, async () => {
|
||
const response = await app.request(`https://pig-learn-test.invalid${path}`, {
|
||
headers: { authorization: `Bearer ${token}` },
|
||
});
|
||
assert.equal(response.status, 401, `${path} must refuse a learn token`);
|
||
});
|
||
}
|
||
|
||
it('answers 401 on those routes with no credential at all, unchanged', async () => {
|
||
// The deploy gate asserts exactly this. Adding a public path must not move
|
||
// it, so it is pinned next to the token case rather than trusted.
|
||
const response = await app.request('https://pig-learn-test.invalid/api/dashboard');
|
||
assert.equal(response.status, 401);
|
||
});
|
||
|
||
it('is not a learn token once it is dressed as a PIG API key', () => {
|
||
// `pig_` is the one prefix that reaches a database lookup, so the two
|
||
// token vocabularies must not overlap in either direction. Asserted on the
|
||
// verifier rather than through the app because the API-key branch needs a
|
||
// real database to answer 401 `invalid_key`, and the e2e suite covers that
|
||
// path with one.
|
||
const dressed = `pig_${token}`;
|
||
assert.equal(verifyLearnToken(ACCESS_CODE, dressed).valid, false);
|
||
assert.equal(dressed.startsWith('learn_'), false);
|
||
});
|
||
});
|
||
|
||
// ----------------------------------------------------------- the rate limiter
|
||
|
||
describe('attempt limiter', () => {
|
||
it('allows the quota, refuses past it, and reopens after the window', () => {
|
||
const limiter = createAttemptLimiter({ limit: 3, windowMs: 60_000 });
|
||
const start = 1_000_000;
|
||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||
assert.equal(limiter.check('10.0.0.9', start).allowed, true);
|
||
}
|
||
const refused = limiter.check('10.0.0.9', start);
|
||
assert.equal(refused.allowed, false);
|
||
assert.ok(refused.retryAfterSeconds > 0);
|
||
|
||
// A window that never reopens is a self-inflicted outage, not security.
|
||
assert.equal(limiter.check('10.0.0.9', start + 60_001).allowed, true);
|
||
// Buckets are per key.
|
||
assert.equal(limiter.check('10.0.0.10', start).allowed, true);
|
||
});
|
||
|
||
it('buckets on the last forwarded hop, not the first', () => {
|
||
// Caddy APPENDS the peer address, so the first entry is whatever the
|
||
// client sent. Keying on it hands anyone unlimited buckets and the limiter
|
||
// becomes decorative.
|
||
assert.equal(rateLimitKey('203.0.113.7, 10.0.0.2'), '10.0.0.2');
|
||
assert.equal(rateLimitKey('10.0.0.2'), '10.0.0.2');
|
||
assert.equal(rateLimitKey(undefined), 'unknown');
|
||
});
|
||
});
|
||
|
||
// ------------------------------------------------- the public round trip
|
||
|
||
/**
|
||
* A self-hosted row must survive the whole read path as a `video`.
|
||
*
|
||
* Asserted through `app.request` on the real public route rather than on the
|
||
* serialiser, because the failure this guards against is composition: the
|
||
* route enumerates its columns, drops rows it cannot rebuild an embed for, and
|
||
* is the one read a code-holder performs. A row that resolves fine in
|
||
* isolation and is silently dropped by `renderable()` would leave the Learn
|
||
* page empty with nothing in the log.
|
||
*/
|
||
describe('a self-hosted row through /api/learn/public', () => {
|
||
const config = loadConfig({
|
||
NODE_ENV: 'production',
|
||
DATABASE_URL: 'postgres://unused:unused@127.0.0.1:1/unused',
|
||
PIG_PUBLIC_URL: 'https://pig-learn-test.invalid',
|
||
SUPABASE_URL: 'https://identity-learn-test.invalid',
|
||
SUPABASE_ANON_KEY: 'learn-test-anon-key',
|
||
SUPABASE_SERVICE_KEY: '',
|
||
PIG_ADMIN_EMAILS: '',
|
||
PIGGY_ENABLED: 'false',
|
||
});
|
||
|
||
const rows = [
|
||
{
|
||
id: '00000000-0000-4000-8000-000000000001',
|
||
track: 'platform' as const,
|
||
title: 'A tour of PIG in five minutes',
|
||
summary: 'What the product is for.',
|
||
provider: 'pig' as const,
|
||
externalId: 'pig-tour.7f3a91c2.mp4',
|
||
visibility: 'code' as const,
|
||
durationSeconds: 300,
|
||
sortOrder: 1,
|
||
publishedAt: new Date('2026-08-01T00:00:00.000Z'),
|
||
},
|
||
{
|
||
id: '00000000-0000-4000-8000-000000000002',
|
||
track: 'platform' as const,
|
||
title: 'The Cap-hosted one, still an iframe',
|
||
summary: null,
|
||
provider: 'cap' as const,
|
||
externalId: '0n6n9p83efnxbs2',
|
||
visibility: 'code' as const,
|
||
durationSeconds: 520,
|
||
sortOrder: 2,
|
||
publishedAt: new Date('2026-08-02T00:00:00.000Z'),
|
||
},
|
||
];
|
||
|
||
/** Answers the settings lookup and the resource read, and nothing else. */
|
||
const db = {
|
||
select: () => ({
|
||
from: (table: unknown) => ({
|
||
where: () => ({
|
||
limit: async () =>
|
||
table === platformSettings ? [{ code: ACCESS_CODE }] : [],
|
||
orderBy: async () => (table === learnResources ? rows : []),
|
||
}),
|
||
}),
|
||
}),
|
||
} as unknown as Database;
|
||
|
||
const authProvider = {
|
||
name: 'learn-test-stub',
|
||
async verifyAccessToken(): Promise<{ subject: string; email: string }> {
|
||
throw new Error('Not a valid identity token.');
|
||
},
|
||
};
|
||
|
||
it('returns kind:"video" for the self-hosted row and kind:"iframe" for the remote one', async () => {
|
||
const app = createApp(config, db, authProvider);
|
||
const token = mintLearnToken(ACCESS_CODE, Date.now() + LEARN_TOKEN_TTL_MS);
|
||
const response = await app.request('https://pig-learn-test.invalid/api/learn/public', {
|
||
headers: { authorization: `Bearer ${token}` },
|
||
});
|
||
assert.equal(response.status, 200);
|
||
|
||
const body = (await response.json()) as {
|
||
resources: { title: string; embed: { kind: string; src: string }; embedUrl: string }[];
|
||
};
|
||
assert.equal(body.resources.length, 2, 'neither row may be dropped');
|
||
|
||
const [hosted, remote] = body.resources;
|
||
assert.deepEqual(hosted?.embed, {
|
||
kind: 'video',
|
||
src: '/media/learn/pig-tour.7f3a91c2.mp4',
|
||
// The poster is derived from the VIDEO's content hash, so a re-render
|
||
// moves both names together and a thumbnail cannot outlive its clip.
|
||
poster: '/media/learn/pig-tour.7f3a91c2.jpg',
|
||
});
|
||
// Same-origin and relative, so the page needs no CSP host for it at all.
|
||
assert.equal(hosted?.embedUrl, '/media/learn/pig-tour.7f3a91c2.mp4');
|
||
assert.equal(remote?.embed.kind, 'iframe');
|
||
assert.equal(remote?.embed.src, 'https://video.karti.ai/embed/0n6n9p83efnxbs2');
|
||
});
|
||
|
||
it('serves that src from the ASSEMBLED app, not just from the route factory', async () => {
|
||
/*
|
||
* The assertion that would have caught the media route shipping unmounted.
|
||
*
|
||
* `describe('the media route')` below exercises createMediaRoutes() as a
|
||
* standalone Hono app, which verifies the handler and proves nothing about
|
||
* whether createApp wires it in. It did not: every layer landed — the
|
||
* migration, the seed, both feeds, the bind mount, the docs — except the
|
||
* one that serves the bytes, and the whole suite stayed green. Requests to
|
||
* /media/learn/* fell through to server.ts's SPA fallback and returned
|
||
* HTTP 200 text/html, so the player showed a black box with working
|
||
* controls and no error.
|
||
*
|
||
* Assert against the composed application, and specifically on the
|
||
* content-type: the failure mode is a 200, not a 404.
|
||
*/
|
||
// The route reads its root from the environment at request time, so point
|
||
// it at a directory that really holds the file the feed advertises.
|
||
const root = mkdtempSync(join(tmpdir(), 'pig-media-mounted-'));
|
||
writeFileSync(join(root, 'pig-tour.7f3a91c2.mp4'), Buffer.alloc(2048, 7));
|
||
const previous = process.env[LEARN_MEDIA_DIR_ENV];
|
||
process.env[LEARN_MEDIA_DIR_ENV] = root;
|
||
|
||
let response: Response;
|
||
try {
|
||
const app = createApp(config, db, authProvider);
|
||
response = await app.request(
|
||
'https://pig-learn-test.invalid/media/learn/pig-tour.7f3a91c2.mp4',
|
||
);
|
||
} finally {
|
||
if (previous === undefined) delete process.env[LEARN_MEDIA_DIR_ENV];
|
||
else process.env[LEARN_MEDIA_DIR_ENV] = previous;
|
||
}
|
||
|
||
assert.notEqual(response.status, 404, 'the media route is not mounted in createApp');
|
||
assert.equal(
|
||
response.headers.get('content-type'),
|
||
'video/mp4',
|
||
'the SPA fallback answered instead of the media route',
|
||
);
|
||
});
|
||
});
|
||
|
||
// --------------------------------------------------------- serving the file
|
||
|
||
describe('the media route', () => {
|
||
const root = mkdtempSync(join(tmpdir(), 'pig-media-'));
|
||
// 1 KiB of distinguishable bytes: an assertion on a range is only meaningful
|
||
// if the wrong offset produces different content.
|
||
const body = Buffer.from(Array.from({ length: 1024 }, (_, i) => i % 251));
|
||
writeFileSync(join(root, 'pig-tour.7f3a91c2.mp4'), body);
|
||
const app = createMediaRoutes({ root });
|
||
const url = (path: string) => `https://pig.invalid${path}`;
|
||
|
||
it('serves the whole file with a seekable header set', async () => {
|
||
const response = await app.request(url('/media/learn/pig-tour.7f3a91c2.mp4'));
|
||
assert.equal(response.status, 200);
|
||
assert.equal(response.headers.get('content-type'), 'video/mp4');
|
||
// Without this a browser will not attempt a range request at all, and the
|
||
// scrubber becomes decorative.
|
||
assert.equal(response.headers.get('accept-ranges'), 'bytes');
|
||
assert.equal(response.headers.get('content-length'), '1024');
|
||
assert.deepEqual(Buffer.from(await response.arrayBuffer()), body);
|
||
});
|
||
|
||
it('answers a range with 206 and exactly those bytes', async () => {
|
||
const response = await app.request(url('/media/learn/pig-tour.7f3a91c2.mp4'), {
|
||
headers: { range: 'bytes=100-199' },
|
||
});
|
||
assert.equal(response.status, 206);
|
||
assert.equal(response.headers.get('content-range'), 'bytes 100-199/1024');
|
||
assert.equal(response.headers.get('content-length'), '100');
|
||
assert.deepEqual(Buffer.from(await response.arrayBuffer()), body.subarray(100, 200));
|
||
});
|
||
|
||
it('answers an open-ended and a suffix range', async () => {
|
||
const open = await app.request(url('/media/learn/pig-tour.7f3a91c2.mp4'), {
|
||
headers: { range: 'bytes=1000-' },
|
||
});
|
||
assert.equal(open.headers.get('content-range'), 'bytes 1000-1023/1024');
|
||
|
||
// How a player finds an MP4 moov atom at the end of the file. Getting this
|
||
// branch wrong is why some videos never start rather than never seek.
|
||
const suffix = await app.request(url('/media/learn/pig-tour.7f3a91c2.mp4'), {
|
||
headers: { range: 'bytes=-24' },
|
||
});
|
||
assert.equal(suffix.headers.get('content-range'), 'bytes 1000-1023/1024');
|
||
assert.deepEqual(Buffer.from(await suffix.arrayBuffer()), body.subarray(1000));
|
||
});
|
||
|
||
it('refuses a range past the end rather than restarting the file', () => {
|
||
// 200-with-the-whole-file here splices byte 0 into the middle of the
|
||
// player's buffer, which corrupts playback instead of failing it.
|
||
assert.equal(parseByteRange('bytes=2000-', 1024), 'unsatisfiable');
|
||
assert.equal(parseByteRange('bytes=500-400', 1024), 'unsatisfiable');
|
||
// A multi-range request is ignored, which the spec permits.
|
||
assert.equal(parseByteRange('bytes=0-10,20-30', 1024), null);
|
||
assert.equal(parseByteRange(undefined, 1024), null);
|
||
});
|
||
|
||
it('answers 416 with the real size so a client can recover', async () => {
|
||
const response = await app.request(url('/media/learn/pig-tour.7f3a91c2.mp4'), {
|
||
headers: { range: 'bytes=5000-' },
|
||
});
|
||
assert.equal(response.status, 416);
|
||
assert.equal(response.headers.get('content-range'), 'bytes */1024');
|
||
});
|
||
|
||
it('answers HEAD without a body, so a player can probe cheaply', async () => {
|
||
const response = await app.request(url('/media/learn/pig-tour.7f3a91c2.mp4'), {
|
||
method: 'HEAD',
|
||
});
|
||
assert.equal(response.status, 200);
|
||
assert.equal(response.headers.get('content-length'), '1024');
|
||
assert.equal((await response.text()).length, 0);
|
||
});
|
||
|
||
it('reads nothing outside the media directory', async () => {
|
||
// The route sees these as filenames; each must 404 rather than resolve.
|
||
for (const path of [
|
||
'/media/learn/..%2f..%2fpackage.json',
|
||
'/media/learn/%2e%2e%2fpackage.json',
|
||
'/media/learn/pig-tour.7f3a91c2.mp4.sh',
|
||
'/media/learn/absent.7f3a91c2.mp4',
|
||
]) {
|
||
const response = await app.request(url(path));
|
||
assert.equal(response.status, 404, `should not serve: ${path}`);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('duration formatting', () => {
|
||
it('crosses the hour without renaming the minutes', () => {
|
||
assert.equal(formatLearnDuration(272), '4:32');
|
||
assert.equal(formatLearnDuration(3_852), '1:04:12');
|
||
assert.equal(formatLearnDuration(60), '1:00');
|
||
assert.equal(formatLearnDuration(null), null);
|
||
assert.equal(formatLearnDuration(-1), null);
|
||
});
|
||
});
|