Files
TREK/plugin-sdk/test/mock-limits.test.ts
jubnl d110b3bb6d Fix/plugin audit findings (#2031)
* fix(plugins): enforce manifest apiVersion at install and activation

* test(plugins): exercise the absent-apiVersion default

* feat(plugins): generate the core-event catalog into host-facts

* docs(plugins): explain the packing.listBags write-permission binding

* test(plugins): cover apiVersion upload refusal; tidy docblocks and generator header

* fix(plugin-sdk): validate settings options and oauth like the host

* fix(plugin-sdk): model the full manifest shape and stop dropping fields

* fix(plugin-sdk): drop non-string routeProfiles icons like the host

* docs(plugin-sdk): document host limits, oauth contract, and ignored asUserId

* feat(plugin-sdk): enforce daily budgets and meta quotas in the mock host

* feat(plugin-sdk): expose the generated core-event catalog

* feat(plugin-sdk): scaffold trek range >=3.4.0 <5.0.0 ahead of TREK 4.0

* feat(plugin-sdk): scaffold the oauth broker settings for oauth:client plugins

* chore(plugin-sdk): fix stale mirror docs and the colliding permissions export

* docs(plugin-sdk): add the rate-limit string; restore trek docblock; match host meta sizing

* feat(plugin-sdk): raise the scaffold trek floor to 4.0.0

* docs(wiki): bring the plugin pages in line with the current runtime

* docs: bring the workspace CLAUDE.md guides up to date and make them committable

* feat(plugin-sdk): roll back failed publishes, add unrelease, sync the fork, retro-sign updates

* docs(wiki): document the publish rollback, unrelease, fork sync, and retro-signing

* chore(plugin-sdk): 1.6.0
2026-08-24 23:48:36 +02:00

135 lines
6.1 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { createMockHost } from '../src/index.js';
/**
* Mock-host fidelity for the two host-mediated daily budgets (ai.complete/extract and
* notify.send, daily-budget.ts) and the ctx.meta quotas (meta.rpc.ts). Error strings
* and check order are copied verbatim from the server so a plugin author sees the
* exact same failure locally that production would give them.
*/
describe('daily budgets', () => {
it('exhausts the AI budget', async () => {
const host = createMockHost({ grants: ['ai:invoke'], actingUserId: 1, aiPerDay: 2 });
await host.ctx.ai.complete('hi');
await host.ctx.ai.complete('hi');
await expect(host.ctx.ai.complete('hi')).rejects.toThrow('daily AI budget exhausted (resets at UTC midnight)');
});
it('ai.extract shares the same per-host AI budget as ai.complete', async () => {
const host = createMockHost({ grants: ['ai:invoke'], actingUserId: 1, aiPerDay: 1 });
await host.ctx.ai.complete('hi');
await expect(host.ctx.ai.extract('text', {})).rejects.toThrow('daily AI budget exhausted (resets at UTC midnight)');
});
it('aiPerDay: 0 disables the broker — the first call fails', async () => {
const host = createMockHost({ grants: ['ai:invoke'], actingUserId: 1, aiPerDay: 0 });
await expect(host.ctx.ai.complete('hi')).rejects.toThrow('daily AI budget exhausted (resets at UTC midnight)');
});
it('defaults to 200/day for ai, far above what a normal test suite calls', async () => {
const host = createMockHost({ grants: ['ai:invoke'], actingUserId: 1 });
for (let i = 0; i < 200; i++) await host.ctx.ai.complete('hi');
await expect(host.ctx.ai.complete('hi')).rejects.toThrow('daily AI budget exhausted (resets at UTC midnight)');
});
it('exhausts the notify budget', async () => {
const host = createMockHost({
grants: ['notify:send'],
actingUserId: 1,
trips: { 1: { members: [1] } },
notifyPerDay: 1,
});
await host.ctx.notify.send({ title: 't', body: 'b', scope: 'user', targetId: 1 });
await expect(host.ctx.notify.send({ title: 't', body: 'b', scope: 'user', targetId: 1 })).rejects.toThrow(
'daily notification budget exhausted (resets at UTC midnight)',
);
});
it('notifyPerDay: 0 disables the broker — the first call fails', async () => {
const host = createMockHost({
grants: ['notify:send'],
actingUserId: 1,
trips: { 1: { members: [1] } },
notifyPerDay: 0,
});
await expect(host.ctx.notify.send({ title: 't', body: 'b', scope: 'user', targetId: 1 })).rejects.toThrow(
'daily notification budget exhausted (resets at UTC midnight)',
);
});
it('defaults to 100/day for notify, far above what a normal test suite calls', async () => {
const host = createMockHost({ grants: ['notify:send'], actingUserId: 1, trips: { 1: { members: [1] } } });
for (let i = 0; i < 100; i++) await host.ctx.notify.send({ title: 't', body: 'b', scope: 'user', targetId: 1 });
await expect(host.ctx.notify.send({ title: 't', body: 'b', scope: 'user', targetId: 1 })).rejects.toThrow(
'daily notification budget exhausted (resets at UTC midnight)',
);
});
});
describe('ctx.meta quotas', () => {
const fixture = () =>
createMockHost({ grants: ['db:meta'], actingUserId: 1, trips: { 1: { members: [1] } } });
it('meta value quota: 65536 bytes passes, 65537 rejects', async () => {
const { ctx } = fixture();
// Size the payload via Buffer.byteLength(JSON.stringify(v)), same as the server's
// notion of "serialized JSON bytes" — a bare string serializes with 2 quote bytes,
// so budget those into the repeat count.
const okValue = 'x'.repeat(65536 - 2);
expect(Buffer.byteLength(JSON.stringify(okValue), 'utf8')).toBe(65536);
await expect(ctx.meta.set('trip', 1, 'k', okValue)).resolves.toEqual({ key: 'k', value: okValue });
const tooBig = 'x'.repeat(65537 - 2);
expect(Buffer.byteLength(JSON.stringify(tooBig), 'utf8')).toBe(65537);
await expect(ctx.meta.set('trip', 1, 'k2', tooBig)).rejects.toThrow('metadata value too large (>65536 bytes)');
});
it('meta key length: 256 chars passes, 257 rejects', async () => {
const { ctx } = fixture();
const okKey = 'k'.repeat(256);
await expect(ctx.meta.set('trip', 1, okKey, 'v')).resolves.toEqual({ key: okKey, value: 'v' });
const tooLongKey = 'k'.repeat(257);
await expect(ctx.meta.set('trip', 1, tooLongKey, 'v')).rejects.toThrow('metadata key too long (>256 chars)');
});
it('meta key count: 100 keys per (plugin, entity) passes, the 101st rejects', async () => {
const { ctx } = fixture();
for (let i = 0; i < 100; i++) {
await ctx.meta.set('trip', 1, `key-${i}`, i);
}
await expect(ctx.meta.set('trip', 1, 'key-100', 100)).rejects.toThrow(
'too many metadata keys on this trip (max 100)',
);
});
it('overwriting an existing key never counts against the 100-key cap', async () => {
const { ctx } = fixture();
for (let i = 0; i < 100; i++) {
await ctx.meta.set('trip', 1, `key-${i}`, i);
}
// The entity already has 100 keys; updating one of them must still succeed.
await expect(ctx.meta.set('trip', 1, 'key-0', 'updated')).resolves.toEqual({ key: 'key-0', value: 'updated' });
});
it('checks in server order: key length before value size before key count', async () => {
const { ctx } = fixture();
// A key that is simultaneously too long AND paired with an oversized value must
// fail on the key-length check first.
const tooLongKey = 'k'.repeat(257);
const tooBigValue = 'x'.repeat(70000);
await expect(ctx.meta.set('trip', 1, tooLongKey, tooBigValue)).rejects.toThrow('metadata key too long (>256 chars)');
// Fill the entity to the cap, then a value that is ALSO too large must fail on
// the value-size check (raised before the key-count check ever runs).
const full = fixture();
for (let i = 0; i < 100; i++) {
await full.ctx.meta.set('trip', 1, `key-${i}`, i);
}
await expect(full.ctx.meta.set('trip', 1, 'new-key', tooBigValue)).rejects.toThrow(
'metadata value too large (>65536 bytes)',
);
});
});