Files
pig/apps/api/test/piggy-chat.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

309 lines
10 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { Hono } from 'hono';
import { platformSettings, teamMemberships, users, type Database } from '@pig/db';
import { createApp } from '../src/app';
import type { Principal } from '../src/lib/auth';
import { loadConfig } from '../src/lib/config';
import type { ApiEnv } from '../src/lib/mutation';
import {
createPiggyChatRoutes,
type PiggyChatProxyOptions,
} from '../src/routes/piggy-chat';
const principal: Principal = {
userId: '10000000-0000-4000-8000-000000000001',
email: 'member@example.com',
name: 'Member',
isPlatformAdmin: false,
teams: [{ team: 'demand', role: 'member' }],
via: 'jwt',
scopes: ['read', 'write'],
};
function appFor(
fetchImpl: typeof fetch,
identity: Principal = principal,
overrides: Partial<PiggyChatProxyOptions> = {},
) {
const app = new Hono<ApiEnv>();
app.use('*', async (context, next) => {
context.set('principal', identity);
await next();
});
app.route(
'/',
createPiggyChatRoutes({
enabled: true,
internalUrl: 'http://127.0.0.1:8931',
internalToken: 'internal-token-with-at-least-32-characters',
fetchImpl,
...overrides,
}),
);
return app;
}
const ndjson = () =>
new Response(`${JSON.stringify({ type: 'done', inputTokens: 1, outputTokens: 1 })}\n`, {
status: 200,
headers: { 'content-type': 'application/x-ndjson' },
});
test('the authenticated proxy forwards bounded identity and relays NDJSON unchanged', async () => {
let forwarded: Record<string, unknown> | undefined;
const fetchImpl: typeof fetch = async (input, init) => {
assert.equal(String(input), 'http://127.0.0.1:8931/internal/chat');
assert.equal(
new Headers(init?.headers).get('authorization'),
'Bearer internal-token-with-at-least-32-characters',
);
forwarded = JSON.parse(String(init?.body)) as Record<string, unknown>;
return new Response(
`${JSON.stringify({ type: 'content_delta', delta: 'Scoped answer' })}\n` +
`${JSON.stringify({ type: 'done', inputTokens: 2, outputTokens: 3 })}\n`,
{ status: 200, headers: { 'content-type': 'application/x-ndjson' } },
);
};
const app = appFor(fetchImpl);
const response = await app.request('/api/piggy/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
message: 'Summarise this contract.',
context: {
type: 'contract',
id: '20000000-0000-4000-8000-000000000002',
label: 'Order form',
},
}),
});
assert.equal(response.status, 200);
assert.match(response.headers.get('content-type') ?? '', /application\/x-ndjson/);
assert.deepEqual(forwarded, {
principalUserId: principal.userId,
message: 'Summarise this contract.',
context: {
type: 'contract',
id: '20000000-0000-4000-8000-000000000002',
label: 'Order form',
},
});
assert.equal(
await response.text(),
`${JSON.stringify({ type: 'content_delta', delta: 'Scoped answer' })}\n` +
`${JSON.stringify({ type: 'done', inputTokens: 2, outputTokens: 3 })}\n`,
);
});
test('a credential without read scope never reaches the internal service', async () => {
let fetched = false;
const app = appFor(
async () => {
fetched = true;
return new Response();
},
{ ...principal, scopes: ['write'] },
);
const response = await app.request('/api/piggy/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ message: 'Read the book.' }),
});
assert.equal(response.status, 403);
assert.equal(fetched, false);
});
test('a docked page context reaches the chat service unaltered', async () => {
let forwarded: Record<string, unknown> | undefined;
const app = appFor(async (_input, init) => {
forwarded = JSON.parse(String(init?.body)) as Record<string, unknown>;
return ndjson();
});
const response = await app.request('/api/piggy/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
message: 'What is idle?',
context: { type: 'page', route: '/capacity', label: 'Capacity' },
}),
});
assert.equal(response.status, 200);
assert.deepEqual(forwarded?.context, {
type: 'page',
route: '/capacity',
label: 'Capacity',
});
});
// A page context carries no record, so admitting one would put a nonsense
// shape in front of the model rather than failing at the boundary.
test('a page context may not smuggle a record id, and an unknown route is refused', async () => {
let fetched = false;
const app = appFor(async () => {
fetched = true;
return ndjson();
});
for (const context of [
{ type: 'page', route: '/not-a-page' },
{ type: 'page', route: '/margin', id: '20000000-0000-4000-8000-000000000002' },
{ type: 'page' },
]) {
const response = await app.request('/api/piggy/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ message: 'Where are we?', context }),
});
assert.equal(response.status, 400);
assert.equal(((await response.json()) as { code: string }).code, 'invalid_request');
}
assert.equal(fetched, false);
});
test('the stored admin toggle disables chat without the environment changing', async () => {
let fetched = false;
let piggyEnabled = true;
const app = appFor(
async () => {
fetched = true;
return ndjson();
},
principal,
{ resolvePiggyEnabled: async () => piggyEnabled },
);
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
enabled: true,
canUse: true,
});
piggyEnabled = false;
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
enabled: false,
canUse: false,
});
const response = await app.request('/api/piggy/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ message: 'Where are we?' }),
});
assert.equal(response.status, 503);
assert.equal(fetched, false);
});
// Losing the settings row must degrade to the environment gate. A dock on
// every page turns one failed query into a site-wide outage otherwise.
test('an unreadable settings row falls back to the environment gate', async () => {
const app = appFor(async () => ndjson(), principal, {
resolvePiggyEnabled: async () => {
throw new Error('platform settings unavailable');
},
});
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
enabled: true,
canUse: true,
});
});
test('the environment gate still overrides a stored toggle that says yes', async () => {
const app = appFor(async () => ndjson(), principal, {
enabled: false,
resolvePiggyEnabled: async () => true,
});
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
enabled: false,
canUse: false,
});
});
// ---------------------------------------------------------------------------
// Composition
// ---------------------------------------------------------------------------
/**
* Enough of a Database to authenticate a development request and read the
* settings row, and nothing more.
*
* Predicates are ignored on purpose: this asserts a WIRING, and a fake that
* tried to execute SQL semantics would be a worse test of the wiring and a
* pointless test of Drizzle. Anything the app queries beyond these three
* tables comes back empty, which is what an untouched deployment looks like.
*/
function stubDatabase(store: { piggyEnabled: boolean }): Database {
const rowsFor = (table: unknown): Record<string, unknown>[] => {
if (table === users) {
return [
{
id: principal.userId,
email: principal.email,
name: principal.name,
isPlatformAdmin: false,
deactivatedAt: null,
},
];
}
if (table === teamMemberships) return [{ team: 'demand', role: 'member' }];
if (table === platformSettings) return [{ piggyEnabled: store.piggyEnabled }];
return [];
};
const query = (rows: Record<string, unknown>[]): Record<string, unknown> => {
const chain: Record<string, unknown> = {
from: (table: unknown) => query(rowsFor(table)),
where: () => chain,
limit: () => chain,
orderBy: () => chain,
innerJoin: () => chain,
leftJoin: () => chain,
values: () => chain,
onConflictDoNothing: () => chain,
returning: () => chain,
then: (resolve: (value: Record<string, unknown>[]) => unknown) => resolve(rows),
};
return chain;
};
return {
select: () => query([]),
insert: (table: unknown) => query(rowsFor(table)),
} as unknown as Database;
}
/**
* The regression this file could not previously catch.
*
* The three toggle tests above build the routes themselves and inject a
* resolver, so every one of them stayed green through a release in which
* `createApp` never passed one — turning Piggy off in the admin UI did nothing
* at all in production. Only a request through the composed app proves the
* stored setting is consulted, so this one goes through `createApp`.
*/
test('createApp wires the stored toggle into the chat routes', async () => {
const store = { piggyEnabled: false };
const config = loadConfig({
NODE_ENV: 'development',
DATABASE_URL: 'postgres://pig:pig@localhost:5432/pig',
PIGGY_ENABLED: 'true',
PIGGY_INTERNAL_URL: 'http://127.0.0.1:8931',
PIGGY_INTERNAL_TOKEN: 'internal-token-with-at-least-32-characters',
});
// Null provider is the development path: no token, principal comes from the
// first user in the table. What is under test is the toggle, not the auth.
const app = createApp(config, stubDatabase(store), null);
assert.equal(config.PIGGY_ENABLED, true);
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
enabled: false,
canUse: false,
});
store.piggyEnabled = true;
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
enabled: true,
canUse: true,
});
});