Rebuild Piggy's interface, and give the demo book a business to describe
Piggy answered in raw markdown, threw away every tool result it streamed, and fought the reader's scroll on every token. The three surfaces that made it worth having — what it read, how it reasoned, what it cost — were all on the wire and none of them reached the screen. The transcript is now composed of five parts under components/piggy: answers render through streamdown, the container sticks to the bottom without pinning the reader there, tool steps say what they read and link to the record, and each turn carries its model and token count. Three lifecycle bugs went with them: Stop left a permanent spinner, a truncated stream was indistinguishable from thinking, and a failed send destroyed the message it failed to send. Underneath, the inference path grew timeouts, jittered retries on 429 and 5xx, tolerance of the malformed frames a 30B model emits, and an agent_runs row per turn so chat spend is observable. The system prompt now states that a field ending in Cents is cents — without it nemotron renders costPerGpuHourCents: 189 as "$189 per GPU-hour", which is a 100x error on the most scrutinised number in the room. The demo book was arithmetically incoherent: every deal's value contradicted its own allocation revenue by up to 3.6x, nothing had ever closed, no customer had any paper, and the marketplace was empty. Deal value is now derived from the allocation, the book clears 5.3% across five blocks with one deliberately underwater, and the renewal, compliance and agent-provenance machinery finally has rows to act on. A --clear that deleted every obligation, SLA term and capacity request in the database regardless of origin is scoped to the demo's own ids. Around that: accounts have a detail page, ⌘K searches the book, Settings can mint the API keys it always claimed to, and deploy.sh actually ships the agent instead of silently skipping its compose profile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import test from 'node:test';
|
||||
import { Hono } from 'hono';
|
||||
import { platformSettings, teamMemberships, users, type Database } from '@pig/db';
|
||||
@@ -21,6 +23,13 @@ const principal: Principal = {
|
||||
scopes: ['read', 'write'],
|
||||
};
|
||||
|
||||
/** Below `member`, so `economics:read` is refused and `book:read` is not. */
|
||||
const viewer: Principal = {
|
||||
...principal,
|
||||
userId: '10000000-0000-4000-8000-000000000002',
|
||||
teams: [{ team: 'demand', role: 'viewer' }],
|
||||
};
|
||||
|
||||
function appFor(
|
||||
fetchImpl: typeof fetch,
|
||||
identity: Principal = principal,
|
||||
@@ -50,9 +59,29 @@ const ndjson = () =>
|
||||
headers: { 'content-type': 'application/x-ndjson' },
|
||||
});
|
||||
|
||||
/**
|
||||
* A chat server that answers the health probe.
|
||||
*
|
||||
* Every route now probes `/internal/health` before it will relay anything, so
|
||||
* a fake that answers only `/internal/chat` makes the relay correctly decide
|
||||
* the service is down and 503 the test it was meant to support.
|
||||
*/
|
||||
function relay(chat: typeof fetch = async () => ndjson()): typeof fetch {
|
||||
return async (input, init) => {
|
||||
if (String(input).endsWith('/internal/health')) return new Response('{"ok":true}');
|
||||
return chat(input, init);
|
||||
};
|
||||
}
|
||||
|
||||
/** Refuses to relay at all: what a dead or key-less Piggy process looks like. */
|
||||
const unhealthy: typeof fetch = async (input, init) => {
|
||||
if (String(input).endsWith('/internal/health')) return new Response('', { status: 503 });
|
||||
return relay()(input, init);
|
||||
};
|
||||
|
||||
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) => {
|
||||
const fetchImpl = relay(async (input, init) => {
|
||||
assert.equal(String(input), 'http://127.0.0.1:8931/internal/chat');
|
||||
assert.equal(
|
||||
new Headers(init?.headers).get('authorization'),
|
||||
@@ -64,7 +93,7 @@ test('the authenticated proxy forwards bounded identity and relays NDJSON unchan
|
||||
`${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',
|
||||
@@ -100,10 +129,10 @@ test('the authenticated proxy forwards bounded identity and relays NDJSON unchan
|
||||
test('a credential without read scope never reaches the internal service', async () => {
|
||||
let fetched = false;
|
||||
const app = appFor(
|
||||
async () => {
|
||||
relay(async () => {
|
||||
fetched = true;
|
||||
return new Response();
|
||||
},
|
||||
return ndjson();
|
||||
}),
|
||||
{ ...principal, scopes: ['write'] },
|
||||
);
|
||||
const response = await app.request('/api/piggy/chat', {
|
||||
@@ -117,10 +146,12 @@ test('a credential without read scope never reaches the internal service', async
|
||||
|
||||
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 app = appFor(
|
||||
relay(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' },
|
||||
@@ -142,10 +173,12 @@ test('a docked page context reaches the chat service unaltered', async () => {
|
||||
// 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();
|
||||
});
|
||||
const app = appFor(
|
||||
relay(async () => {
|
||||
fetched = true;
|
||||
return ndjson();
|
||||
}),
|
||||
);
|
||||
|
||||
for (const context of [
|
||||
{ type: 'page', route: '/not-a-page' },
|
||||
@@ -167,10 +200,10 @@ test('the stored admin toggle disables chat without the environment changing', a
|
||||
let fetched = false;
|
||||
let piggyEnabled = true;
|
||||
const app = appFor(
|
||||
async () => {
|
||||
relay(async () => {
|
||||
fetched = true;
|
||||
return ndjson();
|
||||
},
|
||||
}),
|
||||
principal,
|
||||
{ resolvePiggyEnabled: async () => piggyEnabled },
|
||||
);
|
||||
@@ -197,7 +230,7 @@ test('the stored admin toggle disables chat without the environment changing', a
|
||||
// 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, {
|
||||
const app = appFor(relay(), principal, {
|
||||
resolvePiggyEnabled: async () => {
|
||||
throw new Error('platform settings unavailable');
|
||||
},
|
||||
@@ -209,7 +242,7 @@ test('an unreadable settings row falls back to the environment gate', async () =
|
||||
});
|
||||
|
||||
test('the environment gate still overrides a stored toggle that says yes', async () => {
|
||||
const app = appFor(async () => ndjson(), principal, {
|
||||
const app = appFor(relay(), principal, {
|
||||
enabled: false,
|
||||
resolvePiggyEnabled: async () => true,
|
||||
});
|
||||
@@ -219,6 +252,302 @@ test('the environment gate still overrides a stored toggle that says yes', async
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read authorisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The hole this suite exists for.
|
||||
*
|
||||
* A demand VIEWER is correctly 403'd on `GET /api/capacity/margin` by
|
||||
* `READ_RULES`. Before this, the same person could open the dock on /margin
|
||||
* and have `pig_get_margin_summary` read back book revenue, supplier cost and
|
||||
* break-even — because the relay checked the credential's `read` scope and
|
||||
* never the person's capability, and the chat server receives a bare user id
|
||||
* with no memberships attached to check.
|
||||
*/
|
||||
async function chatWith(
|
||||
identity: Principal,
|
||||
context: unknown,
|
||||
onFetch: () => void = () => {},
|
||||
) {
|
||||
const app = appFor(
|
||||
relay(async () => {
|
||||
onFetch();
|
||||
return ndjson();
|
||||
}),
|
||||
identity,
|
||||
);
|
||||
return app.request('/api/piggy/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(context === undefined ? { message: 'Go on.' } : { message: 'Go on.', context }),
|
||||
});
|
||||
}
|
||||
|
||||
test('a viewer cannot reach the cost book through the dock', async () => {
|
||||
let fetched = false;
|
||||
const denied = [
|
||||
{ type: 'page', route: '/margin' },
|
||||
{ type: 'page', route: '/capacity' },
|
||||
{ type: 'page', route: '/' },
|
||||
// The workspace summary carries book margin, so the page it is served on
|
||||
// does not make it cheaper to read.
|
||||
{ type: 'page', route: '/accounts' },
|
||||
{ type: 'commitment', id: '20000000-0000-4000-8000-000000000003' },
|
||||
// No context at all is the dashboard by another name, and must not be the
|
||||
// way round the gate.
|
||||
undefined,
|
||||
];
|
||||
|
||||
for (const context of denied) {
|
||||
const response = await chatWith(viewer, context, () => {
|
||||
fetched = true;
|
||||
});
|
||||
assert.equal(response.status, 403, JSON.stringify(context));
|
||||
assert.equal(
|
||||
((await response.json()) as { code: string }).code,
|
||||
'insufficient_permission',
|
||||
JSON.stringify(context),
|
||||
);
|
||||
}
|
||||
assert.equal(fetched, false);
|
||||
});
|
||||
|
||||
test('a viewer still reaches the book contexts they can already read', async () => {
|
||||
for (const context of [
|
||||
{ type: 'page', route: '/demand' },
|
||||
{ type: 'page', route: '/contracts' },
|
||||
{ type: 'account', id: '20000000-0000-4000-8000-000000000004' },
|
||||
]) {
|
||||
const response = await chatWith(viewer, context);
|
||||
assert.equal(response.status, 200, JSON.stringify(context));
|
||||
}
|
||||
});
|
||||
|
||||
test('a research lead reads the book but not the margin dock', async () => {
|
||||
const researcher: Principal = { ...viewer, teams: [{ team: 'research', role: 'lead' }] };
|
||||
assert.equal((await chatWith(researcher, { type: 'page', route: '/demand' })).status, 200);
|
||||
assert.equal((await chatWith(researcher, { type: 'page', route: '/margin' })).status, 403);
|
||||
});
|
||||
|
||||
test('a commercial member keeps the margin dock', async () => {
|
||||
assert.equal((await chatWith(principal, { type: 'page', route: '/margin' })).status, 200);
|
||||
});
|
||||
|
||||
test('status tells a viewer the dock is usable and a stranger that it is not', async () => {
|
||||
const stranger: Principal = { ...viewer, teams: [] };
|
||||
assert.deepEqual(await (await appFor(relay(), viewer).request('/api/piggy/status')).json(), {
|
||||
enabled: true,
|
||||
canUse: true,
|
||||
});
|
||||
assert.deepEqual(await (await appFor(relay(), stranger).request('/api/piggy/status')).json(), {
|
||||
enabled: true,
|
||||
canUse: false,
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rate limiting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('a user is capped per hour and told how long to wait', async () => {
|
||||
let relayed = 0;
|
||||
const app = appFor(
|
||||
relay(async () => {
|
||||
relayed += 1;
|
||||
return ndjson();
|
||||
}),
|
||||
principal,
|
||||
{ messagesPerHour: 2 },
|
||||
);
|
||||
const send = () =>
|
||||
app.request('/api/piggy/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'Again.', context: { type: 'page', route: '/margin' } }),
|
||||
});
|
||||
|
||||
assert.equal((await send()).status, 200);
|
||||
assert.equal((await send()).status, 200);
|
||||
|
||||
const limited = await send();
|
||||
assert.equal(limited.status, 429);
|
||||
const body = (await limited.json()) as { code: string; retryAfterSeconds: number };
|
||||
assert.equal(body.code, 'piggy_rate_limited');
|
||||
assert.ok(body.retryAfterSeconds > 0);
|
||||
assert.equal(limited.headers.get('retry-after'), String(body.retryAfterSeconds));
|
||||
// The quota is a spend limit, so nothing past it may reach inference.
|
||||
assert.equal(relayed, 2);
|
||||
});
|
||||
|
||||
/**
|
||||
* Keyed on the user, not the address. Everyone in one office shares an
|
||||
* `X-Forwarded-For`, and one colleague exhausting the credit for the floor is
|
||||
* the failure an address key would produce.
|
||||
*/
|
||||
test('one user exhausting the quota does not silence another', async () => {
|
||||
const routes = createPiggyChatRoutes({
|
||||
enabled: true,
|
||||
internalUrl: 'http://127.0.0.1:8931',
|
||||
internalToken: 'internal-token-with-at-least-32-characters',
|
||||
fetchImpl: relay(),
|
||||
messagesPerHour: 1,
|
||||
});
|
||||
const app = new Hono<ApiEnv>();
|
||||
let identity = principal;
|
||||
app.use('*', async (context, next) => {
|
||||
context.set('principal', identity);
|
||||
await next();
|
||||
});
|
||||
app.route('/', routes);
|
||||
const send = () =>
|
||||
app.request('/api/piggy/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'Again.' }),
|
||||
});
|
||||
|
||||
assert.equal((await send()).status, 200);
|
||||
assert.equal((await send()).status, 429);
|
||||
|
||||
identity = { ...principal, userId: '10000000-0000-4000-8000-000000000009' };
|
||||
assert.equal((await send()).status, 200);
|
||||
});
|
||||
|
||||
test('a refused request does not spend the quota it was never going to use', async () => {
|
||||
const app = appFor(relay(), viewer, { messagesPerHour: 1 });
|
||||
const send = (route: string) =>
|
||||
app.request('/api/piggy/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'Again.', context: { type: 'page', route } }),
|
||||
});
|
||||
|
||||
assert.equal((await send('/margin')).status, 403);
|
||||
assert.equal((await send('/margin')).status, 403);
|
||||
// The one message they are entitled to is still there.
|
||||
assert.equal((await send('/demand')).status, 200);
|
||||
assert.equal((await send('/demand')).status, 429);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Availability
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('a dead chat server is reported as unavailable rather than usable', async () => {
|
||||
const app = appFor(unhealthy);
|
||||
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: 'Anyone there?' }),
|
||||
});
|
||||
assert.equal(response.status, 503);
|
||||
assert.equal(((await response.json()) as { code: string }).code, 'piggy_unavailable');
|
||||
});
|
||||
|
||||
/**
|
||||
* The bug in its original form: `configured` is true, the probe is cached
|
||||
* healthy, and then the socket is refused. That rejection used to reach
|
||||
* `app.onError` and render as a red "Internal error" bubble, which reads as
|
||||
* "Piggy broke on your question" rather than "Piggy is not running".
|
||||
*/
|
||||
test('a connection failure mid-request becomes the clean 503, not an internal error', async () => {
|
||||
const app = appFor(
|
||||
relay(async () => {
|
||||
throw Object.assign(new Error('fetch failed'), { code: 'ECONNREFUSED' });
|
||||
}),
|
||||
);
|
||||
const response = await app.request('/api/piggy/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'Anyone there?' }),
|
||||
});
|
||||
assert.equal(response.status, 503);
|
||||
assert.equal(((await response.json()) as { code: string }).code, 'piggy_unavailable');
|
||||
|
||||
// And the status endpoint stops lying immediately, rather than after the
|
||||
// health cache expires.
|
||||
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
|
||||
enabled: false,
|
||||
canUse: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('a genuinely unreachable port 503s without an injected fetch', async () => {
|
||||
const closed = createServer();
|
||||
await new Promise<void>((resolve) => closed.listen(0, '127.0.0.1', resolve));
|
||||
const port = (closed.address() as AddressInfo).port;
|
||||
await new Promise<void>((resolve) => closed.close(() => resolve()));
|
||||
|
||||
const app = new Hono<ApiEnv>();
|
||||
app.use('*', async (context, next) => {
|
||||
context.set('principal', principal);
|
||||
await next();
|
||||
});
|
||||
app.route(
|
||||
'/',
|
||||
createPiggyChatRoutes({
|
||||
enabled: true,
|
||||
internalUrl: `http://127.0.0.1:${port}`,
|
||||
internalToken: 'internal-token-with-at-least-32-characters',
|
||||
}),
|
||||
);
|
||||
|
||||
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: 'Anyone there?' }),
|
||||
});
|
||||
assert.equal(response.status, 503);
|
||||
assert.equal(((await response.json()) as { code: string }).code, 'piggy_unavailable');
|
||||
});
|
||||
|
||||
// A dock on every page means a status call on every navigation; probing the
|
||||
// chat server on each one would be a loopback flood for no extra truth.
|
||||
test('the health probe is cached and never runs concurrently', async () => {
|
||||
let probes = 0;
|
||||
const app = appFor(async (input) => {
|
||||
if (String(input).endsWith('/internal/health')) {
|
||||
probes += 1;
|
||||
return new Response('{"ok":true}');
|
||||
}
|
||||
return ndjson();
|
||||
});
|
||||
|
||||
await Promise.all(Array.from({ length: 8 }, () => app.request('/api/piggy/status')));
|
||||
assert.equal(probes, 1);
|
||||
await app.request('/api/piggy/status');
|
||||
assert.equal(probes, 1);
|
||||
});
|
||||
|
||||
test('a stale health verdict is re-probed once the cache lapses', async () => {
|
||||
let probes = 0;
|
||||
const app = appFor(
|
||||
async (input) => {
|
||||
if (String(input).endsWith('/internal/health')) {
|
||||
probes += 1;
|
||||
return new Response('{"ok":true}');
|
||||
}
|
||||
return ndjson();
|
||||
},
|
||||
principal,
|
||||
{ healthCacheMs: 0 },
|
||||
);
|
||||
|
||||
await app.request('/api/piggy/status');
|
||||
await app.request('/api/piggy/status');
|
||||
assert.equal(probes, 2);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composition
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -232,7 +561,10 @@ test('the environment gate still overrides a stored toggle that says yes', async
|
||||
* 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 {
|
||||
function stubDatabase(
|
||||
store: { piggyEnabled: boolean },
|
||||
memberships: Record<string, unknown>[] = [{ team: 'demand', role: 'member' }],
|
||||
): Database {
|
||||
const rowsFor = (table: unknown): Record<string, unknown>[] => {
|
||||
if (table === users) {
|
||||
return [
|
||||
@@ -245,7 +577,7 @@ function stubDatabase(store: { piggyEnabled: boolean }): Database {
|
||||
},
|
||||
];
|
||||
}
|
||||
if (table === teamMemberships) return [{ team: 'demand', role: 'member' }];
|
||||
if (table === teamMemberships) return memberships;
|
||||
if (table === platformSettings) return [{ piggyEnabled: store.piggyEnabled }];
|
||||
return [];
|
||||
};
|
||||
@@ -272,6 +604,22 @@ function stubDatabase(store: { piggyEnabled: boolean }): Database {
|
||||
} as unknown as Database;
|
||||
}
|
||||
|
||||
/** A chat server that is up, on a port nothing else in the suite is using. */
|
||||
async function healthServer(): Promise<{ url: string; close: () => Promise<void> }> {
|
||||
const server: Server = createServer((request, response) => {
|
||||
if (request.url === '/internal/health') {
|
||||
response.writeHead(200, { 'content-type': 'application/json' }).end('{"ok":true}');
|
||||
return;
|
||||
}
|
||||
response.writeHead(404).end();
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
return {
|
||||
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
|
||||
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression this file could not previously catch.
|
||||
*
|
||||
@@ -282,27 +630,62 @@ function stubDatabase(store: { piggyEnabled: boolean }): Database {
|
||||
* 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);
|
||||
const piggy = await healthServer();
|
||||
try {
|
||||
const store = { piggyEnabled: false };
|
||||
const config = loadConfig({
|
||||
NODE_ENV: 'development',
|
||||
DATABASE_URL: 'postgres://pig:pig@localhost:5432/pig',
|
||||
PIGGY_ENABLED: 'true',
|
||||
PIGGY_INTERNAL_URL: piggy.url,
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
store.piggyEnabled = true;
|
||||
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
|
||||
enabled: true,
|
||||
canUse: true,
|
||||
});
|
||||
} finally {
|
||||
await piggy.close();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The read guard is mounted before every feature route in `createApp`, and the
|
||||
* chat POST now has a row in that table. This proves the composed app refuses
|
||||
* the turn before the relay is even reached — the relay's own capability check
|
||||
* is the one that can see the context, and this is the floor beneath it.
|
||||
*/
|
||||
test('createApp governs the chat POST with the read guard as well', async () => {
|
||||
const piggy = await healthServer();
|
||||
try {
|
||||
const config = loadConfig({
|
||||
NODE_ENV: 'development',
|
||||
DATABASE_URL: 'postgres://pig:pig@localhost:5432/pig',
|
||||
PIGGY_ENABLED: 'true',
|
||||
PIGGY_INTERNAL_URL: piggy.url,
|
||||
PIGGY_INTERNAL_TOKEN: 'internal-token-with-at-least-32-characters',
|
||||
});
|
||||
// On no team, so no read capability at all — the case the guard exists for.
|
||||
const app = createApp(config, stubDatabase({ piggyEnabled: true }, []), null);
|
||||
|
||||
const response = await app.request('/api/piggy/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'Show me the book.' }),
|
||||
});
|
||||
assert.equal(response.status, 403);
|
||||
} finally {
|
||||
await piggy.close();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user