Files
pig/apps/api/test/learn.test.ts
T
karti 13dec6b4b8
CI / verify (push) Successful in 3m45s
CI / publish (push) Has been skipped
Rebuild the shell, add Calendar and Learn, and govern reads
Seven parallel agents and an adversarial verification pass. The three things
worth knowing before reading the diff:

RBAC WAS ALREADY BUILT. docs/build-plan.md marks F2 and F3 outstanding and is
stale — packages/core/src/permissions.ts and lib/mutation.ts shipped long ago.
So this does not rebuild them; it closes the gaps an audit found. The big one
is that reads were entirely ungoverned: every GET was "any authenticated
member", so a junior demand rep and a research contractor could both pull
per-block supplier cost and break-even prices from /api/capacity/margin, and
every contract's negotiated terms. For a company whose margin is the business,
that was the hole that mattered. Adds book:read / economics:read / team:read,
a readGuard middleware, and a `viewer` role below member.

THE BUTTON AND THE 403 DISAGREED — the exact thing F3 said must never happen.
Contracts.tsx never called can() at all, so its save button was always enabled
against a server requiring contract:sign; Capacity.tsx gated commitment
creation on deal:write/demand while the server wanted commitment:write/supply.

POST /api/activities was the one write bypassing executeMutation: no capability
check, and any member could mutate accounts.lastActivityAt as a side effect.
It is now a proper mutation() behind activity:write.

The shell becomes three panes — a collapsible shadcn sidebar with an account
switcher on the Piggy accent, a header with real search, and Piggy docked to
the right, page-aware and persistent across navigation. The phone keeps its
bottom tab bar, which is the thing this product already beat trycompai/crm on,
and gains the sidebar as a sheet.

Calendar is a projection over thirteen dated sources rather than a new table,
because a table would duplicate dates that already live on contracts, deals and
commitments and would drift — and one ledger answering the question is the
whole argument. It surfaces export_authorizations and compliance_artifacts,
which had indexed expires_at columns, schema comments saying they must be
alerted on, and no read endpoint or UI anywhere.

Learn carries two tracks. Concepts are members-only; the platform track can be
opened with a share code by someone with no account. The code mints a scoped
learn-only token and never a Principal — every route here resolves a principal
and then checks capabilities, so a principal-minting code would be one missing
check away from leaking the book. "Only platform-track rows may be code-visible"
is a database CHECK constraint as well as a write-path rule, and a test asserts
a valid learn token still gets 401 on /api/dashboard, /api/accounts and
/api/contracts — the same invariant scripts/deploy.sh refuses to ship without.

CD becomes tag-to-ship. CI publishes an image to the Gitea registry on a
release-* tag and cloud-2 pulls it, so no credential on the shared runner can
execute anything on production — by construction rather than by policy. Both
halves of deploy.sh's original rule survive: nothing on the runner reaches the
host, and a human still decides when it ships. deploy.sh gains a rollback and a
public-origin check, and PIG_IMAGE now reaches compose through `sudo env`,
without which sudo's env_reset silently resolved every release to pig:local.

Tests 141 -> 261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:02:48 -07:00

281 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
learnEmbedUrl,
learnVisibilityPermitted,
resolveLearnEmbed,
} from '@pig/core';
import type { Database } from '@pig/db';
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 elses 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', () => {
assert.deepEqual(LEARN_FRAME_SRC_HOSTS, ['https://video.karti.ai']);
});
});
// ------------------------------------------------------------- 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');
});
});
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);
});
});