mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
b82962f95b
* feat(shared): scope plugin action descriptors * feat(plugin-sdk): instance-scoped settings actions * feat(plugins): run instance-scoped actions from the admin settings dialog * feat(admin): instance-scoped plugin actions in the settings dialog * fix(plugins): surface instance actions without instance fields and harden the action gate * docs(plugin-sdk): note that hosts before 4.2.0 ignore action scope * fix(admin): open the dialog for instance actions, explain inactive 404s, guard the save window * docs(wiki): plugin pages for instance actions and the admin panel changes
1194 lines
64 KiB
TypeScript
1194 lines
64 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import zlib from 'node:zlib';
|
|
import { createRequire } from 'node:module';
|
|
import { definePlugin, PLUGIN_API_VERSION, validateManifest, createMockHost, PermissionDenied } from '../src/index.js';
|
|
import { isUnboundedRange } from '../src/manifest.js';
|
|
import { scaffold } from '../src/cli/create.js';
|
|
import { validatePluginDir } from '../src/cli/validate.js';
|
|
import { makeZip, listZipNames } from '../src/zip.js';
|
|
import { packPluginDir } from '../src/cli/pack.js';
|
|
import { buildEntry } from '../src/cli/entry.js';
|
|
import { generateKeypair, signArtifact, publicKeyBase64, verifyArtifact, loadPrivateKey } from '../src/cli/sign.js';
|
|
import { makePublishable } from './helpers.js';
|
|
|
|
/** A central-directory zip reader mirroring the TREK server's, to prove round-trip. */
|
|
function readZip(buf: Buffer): Record<string, Buffer> {
|
|
let e = -1;
|
|
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { e = i; break; }
|
|
const count = buf.readUInt16LE(e + 8);
|
|
let p = buf.readUInt32LE(e + 16);
|
|
const out: Record<string, Buffer> = {};
|
|
for (let i = 0; i < count; i++) {
|
|
const method = buf.readUInt16LE(p + 10);
|
|
const compSize = buf.readUInt32LE(p + 20);
|
|
const nameLen = buf.readUInt16LE(p + 28);
|
|
const extraLen = buf.readUInt16LE(p + 30);
|
|
const commentLen = buf.readUInt16LE(p + 32);
|
|
const lho = buf.readUInt32LE(p + 42);
|
|
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
|
|
const dataStart = lho + 30 + buf.readUInt16LE(lho + 26) + buf.readUInt16LE(lho + 28);
|
|
const comp = buf.subarray(dataStart, dataStart + compSize);
|
|
out[name] = method === 0 ? Buffer.from(comp) : zlib.inflateRawSync(comp);
|
|
p += 46 + nameLen + extraLen + commentLen;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
describe('definePlugin + api version', () => {
|
|
it('returns the definition and exposes the api version', () => {
|
|
const def = { onLoad: async () => {} };
|
|
expect(definePlugin(def)).toBe(def);
|
|
expect(PLUGIN_API_VERSION).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('validateManifest', () => {
|
|
const base = { id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', apiVersion: 1, trek: '>=3.2.0 <4.0.0' };
|
|
it('accepts a valid manifest', () => {
|
|
expect(validateManifest(base).ok).toBe(true);
|
|
});
|
|
it('collects every problem', () => {
|
|
const r = validateManifest({ id: 'Bad', version: '1.x', type: 'nope', permissions: ['fs:read'] });
|
|
expect(r.ok).toBe(false);
|
|
expect(r.errors.length).toBeGreaterThan(2);
|
|
});
|
|
it('requires egress when http:outbound is declared', () => {
|
|
expect(validateManifest({ ...base, permissions: ['http:outbound'] }).ok).toBe(false);
|
|
expect(validateManifest({ ...base, permissions: ['http:outbound'], egress: ['api.x.com'] }).ok).toBe(true);
|
|
});
|
|
it('waives the egress requirement for an operatorEgress plugin', () => {
|
|
// A self-hosted target (Gotify, ntfy) has no host the author can name at publish
|
|
// time — the admin supplies it after install, and TREK blocks all outbound until then.
|
|
expect(validateManifest({ ...base, permissions: ['http:outbound'], operatorEgress: true }).ok).toBe(true);
|
|
// The waiver is tied to the flag: no flag, still an error.
|
|
expect(validateManifest({ ...base, permissions: ['http:outbound'], operatorEgress: false }).ok).toBe(false);
|
|
// …and the flag alone never grants reach: it still needs an http:outbound permission.
|
|
expect(validateManifest({ ...base, permissions: ['db:own'], operatorEgress: true }).ok).toBe(false);
|
|
});
|
|
it('rejects native modules and non-objects', () => {
|
|
expect(validateManifest({ ...base, nativeModules: true }).ok).toBe(false);
|
|
expect(validateManifest('nope').ok).toBe(false);
|
|
});
|
|
it('accepts the newer host permissions (writes, notify, map-marker hook)', () => {
|
|
const permissions = ['db:write:reservations', 'db:write:accommodations', 'notify:send', 'hook:map-marker-provider'];
|
|
const r = validateManifest({ ...base, permissions });
|
|
expect(r.errors).toEqual([]);
|
|
expect(r.ok).toBe(true);
|
|
expect(r.manifest?.permissions).toEqual(permissions);
|
|
});
|
|
it('accepts hook:trip-card-provider and hook:user-data (server accepts them)', () => {
|
|
const permissions = ['hook:trip-card-provider', 'hook:user-data'];
|
|
const r = validateManifest({ ...base, permissions });
|
|
expect(r.ok).toBe(true);
|
|
expect(r.manifest?.permissions).toEqual(permissions);
|
|
});
|
|
|
|
it('accepts hook:map-layer-provider, hook:day-schedule-provider, hook:day-tint-provider and geolocation:read', () => {
|
|
const r = validateManifest({ ...base, permissions: ['hook:map-layer-provider', 'hook:day-schedule-provider', 'hook:day-tint-provider', 'geolocation:read'] });
|
|
expect(r.errors).toEqual([]);
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it('validates capabilities.routeProfiles and ties it to hook:route-provider', () => {
|
|
const profiles = [{ id: 'ev', label: 'EV' }];
|
|
const ok = validateManifest({ ...base, permissions: ['hook:route-provider'], capabilities: { routeProfiles: profiles } });
|
|
expect(ok.errors).toEqual([]);
|
|
expect(ok.ok).toBe(true);
|
|
// profiles without the grant are dead buttons — rejected at validate time
|
|
expect(validateManifest({ ...base, capabilities: { routeProfiles: profiles } }).ok).toBe(false);
|
|
// id shape, label cap, count cap, duplicates
|
|
const bad = (routeProfiles: unknown) => validateManifest({ ...base, permissions: ['hook:route-provider'], capabilities: { routeProfiles } }).ok;
|
|
expect(bad([{ id: 'EV', label: 'x' }])).toBe(false);
|
|
expect(bad([{ id: 'ev' }])).toBe(false);
|
|
expect(bad([{ id: 'ev', label: 'L'.repeat(41) }])).toBe(false);
|
|
expect(bad([{ id: 'ev', label: 'a' }, { id: 'ev', label: 'b' }])).toBe(false);
|
|
expect(bad([1, 2, 3, 4].map(i => ({ id: `p${i}`, label: 'x' })))).toBe(false);
|
|
expect(bad('ev')).toBe(false);
|
|
});
|
|
|
|
it('accepts the read-symmetry + broker permissions (collab, file content, trip create, rates)', () => {
|
|
const permissions = ['db:read:collab', 'db:create:trips', 'rates:read', 'db:read:files:content'];
|
|
const r = validateManifest({ ...base, permissions });
|
|
expect(r.errors).toEqual([]);
|
|
expect(r.ok).toBe(true);
|
|
expect(r.manifest?.permissions).toEqual(permissions);
|
|
});
|
|
it('validates tripPage: replaceable tabs only (never plan), position 0-50', () => {
|
|
const page = { ...base, type: 'trip-page' };
|
|
expect(validateManifest({ ...page, capabilities: { tripPage: { replaces: ['transports', 'buchungen'], position: 1 } } }).ok).toBe(true);
|
|
expect(validateManifest({ ...page, capabilities: { tripPage: { replaces: ['plan'] } } }).ok).toBe(false);
|
|
expect(validateManifest({ ...page, capabilities: { tripPage: { replaces: ['nope'] } } }).ok).toBe(false);
|
|
expect(validateManifest({ ...page, capabilities: { tripPage: { position: -1 } } }).ok).toBe(false);
|
|
});
|
|
it('validates settingsUi as a boolean', () => {
|
|
expect(validateManifest({ ...base, capabilities: { settingsUi: true } }).ok).toBe(true);
|
|
expect(validateManifest({ ...base, capabilities: { settingsUi: false } }).ok).toBe(true);
|
|
expect(validateManifest({ ...base, capabilities: { settingsUi: 'yes' } }).ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('createMockHost', () => {
|
|
it('mocks plugin UI session storage with scope isolation and production limits', async () => {
|
|
const host = createMockHost({ sessionTripId: 42 });
|
|
await host.session.set('filters', ['hotel']);
|
|
await host.session.set('filters', ['flight'], { scope: 'trip' });
|
|
await expect(host.session.get('filters')).resolves.toEqual(['hotel']);
|
|
await expect(host.session.get('filters', { scope: 'trip' })).resolves.toEqual(['flight']);
|
|
|
|
await expect(host.session.set('x'.repeat(65), true)).rejects.toThrow(/SESSION_INVALID_KEY/);
|
|
await expect(host.session.set('large', 'x'.repeat(1024))).rejects.toThrow(/SESSION_VALUE_TOO_LARGE/);
|
|
await expect(host.session.set('x'.repeat(64), 'x'.repeat(1022))).resolves.toBeUndefined();
|
|
|
|
await host.session.clear();
|
|
await host.session.clear({ scope: 'trip' });
|
|
for (let index = 0; index < 32; index += 1) await host.session.set(`key-${index}`, index);
|
|
await expect(host.session.set('key-32', 32)).rejects.toThrow(/SESSION_KEY_LIMIT/);
|
|
await expect(host.session.set('trip-key', true, { scope: 'trip' })).resolves.toBeUndefined();
|
|
|
|
});
|
|
|
|
it('requires a trip context for trip-scoped mock session storage', async () => {
|
|
const { session } = createMockHost();
|
|
await expect(session.get('filters', { scope: 'trip' })).rejects.toThrow(/NO_TRIP_CONTEXT/);
|
|
});
|
|
|
|
it('enforces the granted permission set', async () => {
|
|
const { ctx } = createMockHost({ grants: ['db:own'] });
|
|
await expect(ctx.db.migrate('1', 'CREATE TABLE t (x)')).resolves.toEqual({ applied: true });
|
|
await expect(ctx.trips.getById(1, 1)).rejects.toThrow(/PERMISSION_DENIED/);
|
|
});
|
|
|
|
it('membership-checks trip reads against the ACTING user (asUserId is ignored, like the real host) and records broadcasts', async () => {
|
|
const member = createMockHost({
|
|
grants: ['db:read:trips', 'ws:broadcast:trip'],
|
|
actingUserId: 42,
|
|
trips: { 1: { members: [42], data: { id: 1, name: 'Japan' } } },
|
|
});
|
|
expect(await member.ctx.trips.getById(1)).toEqual({ id: 1, name: 'Japan' });
|
|
await member.ctx.ws.broadcastToTrip(1, 'ping', { a: 1 });
|
|
expect(member.broadcasts).toEqual([{ kind: 'trip', target: 1, event: 'ping', data: { a: 1 } }]);
|
|
// asUserId is accepted for source-compat but IGNORED: a non-member acting user is
|
|
// refused even when it passes a member id as asUserId (the #13 divergence, now fixed).
|
|
const outsider = createMockHost({
|
|
grants: ['db:read:trips'],
|
|
actingUserId: 99,
|
|
trips: { 1: { members: [42], data: { id: 1, name: 'Japan' } } },
|
|
});
|
|
await expect(outsider.ctx.trips.getById(1, 42)).rejects.toThrow(/RESOURCE_FORBIDDEN/);
|
|
});
|
|
|
|
it('returns canned db.query results + records logs', async () => {
|
|
const { ctx, logs } = createMockHost({ grants: ['db:own'], queryResults: { 'SELECT 1': [{ n: 1 }] } });
|
|
expect(await ctx.db.query('SELECT 1')).toEqual([{ n: 1 }]);
|
|
ctx.log.info('hi');
|
|
expect(logs).toEqual([{ level: 'info', msg: 'hi' }]);
|
|
});
|
|
|
|
it('gates costs reads/writes on the grant, addon, membership and edit permission', async () => {
|
|
const { ctx } = createMockHost({
|
|
grants: ['db:read:costs', 'db:write:costs'],
|
|
actingUserId: 42,
|
|
trips: {
|
|
1: { members: [42], costs: [{ id: 5, name: 'Hotel' }] },
|
|
2: { members: [42], costs: [{ id: 6, name: 'Food' }], canEditCosts: false },
|
|
},
|
|
});
|
|
// read: trip-scoped + cross-trip aggregate
|
|
expect(await ctx.costs.getByTrip(1)).toEqual([{ id: 5, name: 'Hotel' }]);
|
|
expect(await ctx.costs.listMine()).toEqual([{ id: 5, name: 'Hotel' }, { id: 6, name: 'Food' }]);
|
|
// write: allowed where the user may edit, refused where they may not
|
|
expect(await ctx.costs.create(1, { name: 'Taxi' })).toMatchObject({ trip_id: 1, name: 'Taxi' });
|
|
await expect(ctx.costs.create(2, { name: 'Nope' })).rejects.toThrow(/RESOURCE_FORBIDDEN/);
|
|
// update: edits an existing item where allowed, refused without edit permission
|
|
expect(await ctx.costs.update(1, 5, { name: 'Hostel' })).toMatchObject({ id: 5, name: 'Hostel' });
|
|
await expect(ctx.costs.update(2, 6, { name: 'Nope' })).rejects.toThrow(/RESOURCE_FORBIDDEN/);
|
|
// update: a missing item is refused
|
|
await expect(ctx.costs.update(1, 999, { name: 'X' })).rejects.toThrow(/RESOURCE_FORBIDDEN/);
|
|
// delete: removes where allowed, refused without edit permission / when missing
|
|
expect(await ctx.costs.delete(1, 5)).toEqual({ deleted: true });
|
|
await expect(ctx.costs.delete(2, 6)).rejects.toThrow(/RESOURCE_FORBIDDEN/);
|
|
await expect(ctx.costs.delete(1, 999)).rejects.toThrow(/RESOURCE_FORBIDDEN/);
|
|
});
|
|
|
|
it('gates planner writes (places/days/itinerary/trips) on grant, membership and edit permission', async () => {
|
|
const { ctx } = createMockHost({
|
|
grants: ['db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips'],
|
|
actingUserId: 42,
|
|
trips: {
|
|
1: { members: [42], data: { id: 1, title: 'Japan' } },
|
|
2: { members: [42], canEditPlaces: false },
|
|
},
|
|
});
|
|
// create + assign on a trip the user may edit
|
|
const place = await ctx.places.create(1, { name: 'Fushimi Inari' });
|
|
expect(place).toMatchObject({ trip_id: 1, name: 'Fushimi Inari' });
|
|
const day = await ctx.days.create(1, { notes: 'Day 1' });
|
|
expect(await ctx.itinerary.assign(1, (day as { id: number }).id, (place as { id: number }).id))
|
|
.toMatchObject({ day_id: (day as { id: number }).id });
|
|
expect(await ctx.trips.update(1, { title: 'Renamed' })).toMatchObject({ title: 'Renamed' });
|
|
// refused where the user may not edit
|
|
await expect(ctx.places.create(2, { name: 'Nope' })).rejects.toThrow(/RESOURCE_FORBIDDEN/);
|
|
});
|
|
|
|
it('refuses a planner write without the matching write scope', async () => {
|
|
const { ctx } = createMockHost({ grants: ['db:read:trips'], actingUserId: 42, trips: { 1: { members: [42] } } });
|
|
await expect(ctx.places.create(1, { name: 'X' })).rejects.toThrow(/PERMISSION_DENIED/);
|
|
});
|
|
|
|
it('ctx.meta stores/reads/lists/deletes namespaced metadata, gated on db:meta', async () => {
|
|
const { ctx } = createMockHost({ grants: ['db:meta'], actingUserId: 42, trips: { 1: { members: [42] } } });
|
|
await ctx.meta.set('trip', 1, 'rating', 5);
|
|
expect(await ctx.meta.get('trip', 1, 'rating')).toBe(5);
|
|
expect(await ctx.meta.list('trip', 1)).toEqual({ rating: 5 });
|
|
expect(await ctx.meta.delete('trip', 1, 'rating')).toEqual({ deleted: true });
|
|
expect(await ctx.meta.get('trip', 1, 'rating')).toBe(null);
|
|
const ungranted = createMockHost({ grants: [], actingUserId: 42, trips: { 1: { members: [42] } } });
|
|
await expect(ungranted.ctx.meta.get('trip', 1, 'x')).rejects.toThrow(/PERMISSION_DENIED/);
|
|
});
|
|
|
|
it('serves days + accommodations from the trip fixture and creates lodging blocks', async () => {
|
|
const { ctx } = createMockHost({
|
|
grants: ['db:read:trips', 'db:write:accommodations'],
|
|
actingUserId: 42,
|
|
trips: {
|
|
1: {
|
|
members: [42],
|
|
days: [{ id: 1, date: '2026-07-01' }, { id: 2, date: '2026-07-02' }],
|
|
accommodations: [{ id: 9, place_id: 3, start_day_id: 1, end_day_id: 2 }],
|
|
},
|
|
},
|
|
});
|
|
expect(await ctx.trips.getDays(1)).toEqual([{ id: 1, date: '2026-07-01' }, { id: 2, date: '2026-07-02' }]);
|
|
expect(await ctx.trips.getAccommodations(1)).toHaveLength(1);
|
|
const created = await ctx.accommodations.create(1, { place_id: 7, start_day_id: 1, end_day_id: 2 });
|
|
expect(created).toMatchObject({ trip_id: 1, place_id: 7 });
|
|
expect(await ctx.trips.getAccommodations(1)).toHaveLength(2);
|
|
// ungranted read stays refused
|
|
const ungranted = createMockHost({ grants: [], actingUserId: 42, trips: { 1: { members: [42] } } });
|
|
await expect(ungranted.ctx.trips.getDays(1)).rejects.toThrow(/PERMISSION_DENIED/);
|
|
});
|
|
|
|
it('removeMember + journey create/delete round out the write symmetry, grant-gated', async () => {
|
|
const host = createMockHost({
|
|
grants: ['db:write:members', 'db:write:journal'],
|
|
actingUserId: 42,
|
|
trips: { 1: { members: [42, 7] } },
|
|
});
|
|
expect(await host.ctx.trips.removeMember(1, 7)).toEqual({ removed: true });
|
|
expect(await host.ctx.trips.removeMember(1, 999)).toEqual({ removed: true }); // prod's DELETE reports removed:true either way
|
|
const j = await host.ctx.journal.createJourney({ title: 'Imported', trip_ids: [1] });
|
|
expect(j).toMatchObject({ title: 'Imported' });
|
|
expect(await host.ctx.journal.deleteJourney(1)).toEqual({ deleted: true });
|
|
// grants enforced identically to production
|
|
const ungranted = createMockHost({ grants: [], actingUserId: 42, trips: { 1: { members: [42, 7] } } });
|
|
await expect(ungranted.ctx.trips.removeMember(1, 7)).rejects.toThrow(/PERMISSION_DENIED/);
|
|
await expect(ungranted.ctx.journal.createJourney({ title: 'x' })).rejects.toThrow(/PERMISSION_DENIED/);
|
|
});
|
|
|
|
it('journal.addEntryPhoto refuses in the mock what the host refuses in production', async () => {
|
|
const png = Buffer.from('89504e470d0a1a0a', 'hex').toString('base64');
|
|
const host = createMockHost({
|
|
grants: ['db:write:journal'],
|
|
actingUserId: 42,
|
|
journals: [{ id: 1, title: 'Japan' }],
|
|
journalEntries: [{ id: 5, journey_id: 1, entry_date: '2027-04-01' }],
|
|
});
|
|
|
|
const photo = await host.ctx.journal.addEntryPhoto(5, { name: 'tokyo.jpg', content_base64: png, caption: 'Arrival' });
|
|
expect(photo).toMatchObject({ entry_id: 5, caption: 'Arrival' });
|
|
|
|
// An importer that hits one of these in the mock would hit it in production too,
|
|
// which is the whole point of the double refusing the same things.
|
|
await expect(host.ctx.journal.addEntryPhoto(999, { name: 'a.jpg', content_base64: png }))
|
|
.rejects.toThrow(/RESOURCE_FORBIDDEN/);
|
|
await expect(host.ctx.journal.addEntryPhoto(5, { name: 'map.svg', content_base64: png }))
|
|
.rejects.toThrow(/not an allowed image type/);
|
|
await expect(host.ctx.journal.addEntryPhoto(5, { name: 'noext', content_base64: png }))
|
|
.rejects.toThrow(/not an allowed image type/);
|
|
await expect(host.ctx.journal.addEntryPhoto(5, { name: '', content_base64: png }))
|
|
.rejects.toThrow(/name is required/);
|
|
await expect(host.ctx.journal.addEntryPhoto(5, { name: 'a.jpg', content_base64: '' }))
|
|
.rejects.toThrow(/content_base64 is required/);
|
|
await expect(host.ctx.journal.addEntryPhoto(5, { name: 'a.jpg', content_base64: 'A'.repeat(15 * 1024 * 1024) }))
|
|
.rejects.toThrow(/10MB plugin upload cap/);
|
|
|
|
const ungranted = createMockHost({ grants: [], actingUserId: 42, journalEntries: [{ id: 5, journey_id: 1 }] });
|
|
await expect(ungranted.ctx.journal.addEntryPhoto(5, { name: 'a.jpg', content_base64: png }))
|
|
.rejects.toThrow(/PERMISSION_DENIED/);
|
|
});
|
|
|
|
it('creates a trip for the acting user and serves rates + collab reads against the grants', async () => {
|
|
const { ctx } = createMockHost({
|
|
grants: ['db:create:trips', 'rates:read', 'db:read:collab'],
|
|
actingUserId: 42,
|
|
trips: { 1: { members: [42], notes: [{ id: 1, title: 'Museum tips' }] } },
|
|
ratesResult: { USD: 1.08, GBP: 0.85 },
|
|
});
|
|
expect(await ctx.trips.create({ title: 'Japan 2027', currency: 'JPY' })).toMatchObject({ title: 'Japan 2027', user_id: 42 });
|
|
expect(await ctx.collab.listNotes(1)).toEqual([{ id: 1, title: 'Museum tips' }]);
|
|
expect(await ctx.rates.get('EUR')).toEqual({ USD: 1.08, GBP: 0.85 });
|
|
// ungranted capability (file content) stays refused
|
|
await expect(ctx.files.getContent(1, 1)).rejects.toThrow(/PERMISSION_DENIED/);
|
|
});
|
|
|
|
it('serves file bytes via files.getContent under db:read:files:content', async () => {
|
|
const content = Buffer.from('voucher bytes').toString('base64');
|
|
const { ctx } = createMockHost({
|
|
grants: ['db:read:files:content'],
|
|
actingUserId: 42,
|
|
trips: { 1: { members: [42], files: [{ id: 3, name: 'voucher.pdf', mimetype: 'application/pdf', content_base64: content }] } },
|
|
});
|
|
expect(await ctx.files.getContent(1, 3)).toEqual({ name: 'voucher.pdf', mimetype: 'application/pdf', size: 13, content_base64: content });
|
|
await expect(ctx.files.getContent(1, 99)).rejects.toThrow(/RESOURCE_FORBIDDEN/);
|
|
});
|
|
|
|
it('refuses costs when the permission is missing or the addon is disabled', async () => {
|
|
const ungranted = createMockHost({ grants: [], actingUserId: 42, trips: { 1: { members: [42] } } });
|
|
await expect(ungranted.ctx.costs.getByTrip(1)).rejects.toThrow(/PERMISSION_DENIED/);
|
|
|
|
const addonOff = createMockHost({
|
|
grants: ['db:read:costs'],
|
|
actingUserId: 42,
|
|
budgetAddonEnabled: false,
|
|
trips: { 1: { members: [42], costs: [] } },
|
|
});
|
|
await expect(addonOff.ctx.costs.getByTrip(1)).rejects.toThrow(/RESOURCE_FORBIDDEN/);
|
|
});
|
|
|
|
it('host.run drives the plugin\'s own handlers (route, job, scheduled, event, GDPR, hook)', async () => {
|
|
const seen: string[] = [];
|
|
const def = definePlugin({
|
|
routes: [{ method: 'GET', path: '/ping', async handler(req) { return { status: 200, body: { user: req.user?.id } }; } }],
|
|
jobs: [{ id: 'refresh', schedule: '* * * * *', async handler() { seen.push('job'); } }],
|
|
async scheduled({ name, payload }) { seen.push(`sched:${name}:${JSON.stringify(payload)}`); },
|
|
events: [{ on: 'place:created', handler(p) { seen.push(`evt:${p.event}:${p.entityId}`); } }],
|
|
async deleteUserData({ userId }) { seen.push(`del:${userId}`); },
|
|
async exportUserData({ userId }) { return { userId, rows: 2 }; },
|
|
hooks: { tripCardProvider: { async getCards(tripIds) { return tripIds.map((id) => ({ tripId: id, id: 'b', label: 'X' })); } } },
|
|
});
|
|
// Every entry point needs the grant TREK would gate it on — events and the GDPR
|
|
// handlers included, or the driver refuses them exactly as the host would.
|
|
const host = createMockHost({
|
|
grants: ['jobs:run', 'events:subscribe', 'hook:user-data', 'hook:trip-card-provider'],
|
|
actingUserId: 7,
|
|
});
|
|
const d = host.run(def);
|
|
|
|
const res = await d.route({ method: 'GET', path: '/ping' });
|
|
expect(res).toEqual({ status: 200, body: { user: 7 } });
|
|
await d.job('refresh');
|
|
await d.scheduled('daily', { x: 1 });
|
|
await d.event('place:created', { tripId: 1, entity: 'place', entityId: 9 });
|
|
await d.deleteUserData(42);
|
|
expect(await d.exportUserData(42)).toEqual({ userId: 42, rows: 2 });
|
|
expect(await d.hook('tripCardProvider', 'getCards', [1, 2])).toEqual([
|
|
{ tripId: 1, id: 'b', label: 'X' }, { tripId: 2, id: 'b', label: 'X' },
|
|
]);
|
|
expect(seen).toEqual(['job', 'sched:daily:{"x":1}', 'evt:place:created:9', 'del:42']);
|
|
|
|
// missing handlers / unknown ids throw a clear error, not a silent no-op
|
|
await expect(d.job('nope')).rejects.toThrow(/no job/);
|
|
await expect(host.run(definePlugin({})).scheduled('x')).rejects.toThrow(/no scheduled handler/);
|
|
});
|
|
|
|
// TREK gates hooks, events, jobs and the GDPR handlers on a permission BEFORE the plugin
|
|
// is reached, and skips an ungranted plugin silently — no error, no log, it just never
|
|
// runs. The driver must refuse them, or a green unit test still means a dead plugin.
|
|
it('refuses every entry point the manifest did not grant, the way TREK would', async () => {
|
|
const ran: string[] = [];
|
|
const def = definePlugin({
|
|
jobs: [{ id: 'refresh', schedule: '* * * * *', async handler() { ran.push('job'); } }],
|
|
async scheduled() { ran.push('scheduled'); },
|
|
events: [{ on: 'place:created', handler() { ran.push('event'); } }],
|
|
async deleteUserData() { ran.push('delete'); },
|
|
async exportUserData() { ran.push('export'); return {}; },
|
|
hooks: {
|
|
warningProvider: { async getWarnings() { ran.push('warn'); return []; } },
|
|
notificationChannel: { async send() { ran.push('send'); }, async test() { ran.push('test'); } },
|
|
},
|
|
});
|
|
const d = createMockHost({ grants: [] }).run(def); // implements everything, granted nothing
|
|
|
|
await expect(d.job('refresh')).rejects.toThrow(PermissionDenied);
|
|
await expect(d.job('refresh')).rejects.toThrow(/requires jobs:run/);
|
|
await expect(d.scheduled('daily')).rejects.toThrow(/requires jobs:run/);
|
|
await expect(d.event('place:created')).rejects.toThrow(/requires events:subscribe/);
|
|
await expect(d.deleteUserData(1)).rejects.toThrow(/requires hook:user-data/);
|
|
await expect(d.exportUserData(1)).rejects.toThrow(/requires hook:user-data/);
|
|
await expect(d.hook('warningProvider', 'getWarnings', 1)).rejects.toThrow(/requires hook:trip-warning-provider/);
|
|
await expect(d.channel.send({ event: 'todo_due', title: 'T', body: 'B' })).rejects.toThrow(/requires hook:notification-channel/);
|
|
await expect(d.channel.test()).rejects.toThrow(/requires hook:notification-channel/);
|
|
|
|
// Not one handler ran. That is the production behaviour this mirrors.
|
|
expect(ran).toEqual([]);
|
|
});
|
|
|
|
it('runs the same entry points once the grants are there', async () => {
|
|
const ran: string[] = [];
|
|
const def = definePlugin({
|
|
jobs: [{ id: 'refresh', schedule: '* * * * *', async handler() { ran.push('job'); } }],
|
|
events: [{ on: 'place:created', handler() { ran.push('event'); } }],
|
|
hooks: { warningProvider: { async getWarnings() { ran.push('warn'); return []; } } },
|
|
});
|
|
const d = createMockHost({ grants: ['jobs:run', 'events:subscribe', 'hook:trip-warning-provider'] }).run(def);
|
|
await d.job('refresh');
|
|
await d.event('place:created');
|
|
await d.hook('warningProvider', 'getWarnings', 1);
|
|
expect(ran).toEqual(['job', 'event', 'warn']);
|
|
});
|
|
|
|
it('exposes the scheduler timers a plugin armed via ctx.scheduler', async () => {
|
|
const def = definePlugin({ async onLoad(ctx) { await ctx.scheduler.every(3_600_000, 'sync', { n: 1 }); } });
|
|
const host = createMockHost({ grants: ['jobs:run'] });
|
|
await host.run(def).load();
|
|
expect(host.scheduled.get('sync')).toMatchObject({ everyMs: 3_600_000, payload: { n: 1 } });
|
|
});
|
|
|
|
it('runs jobs USERLESS like production — a membership read refuses, the same read from a route works', async () => {
|
|
const seen: string[] = [];
|
|
const def = definePlugin({
|
|
jobs: [{ id: 'sync', schedule: '* * * * *', async handler(ctx) {
|
|
try { await ctx.trips.getById(1); seen.push('read-ok'); } catch (e) { seen.push((e as Error).message.split(':')[0]); }
|
|
} }],
|
|
routes: [{ method: 'GET', path: '/t', async handler(_req, ctx) { return { status: 200, body: await ctx.trips.getById(1) }; } }],
|
|
});
|
|
const host = createMockHost({ grants: ['db:read:trips', 'jobs:run'], actingUserId: 42, trips: { 1: { members: [42], data: { id: 1 } } } });
|
|
const d = host.run(def);
|
|
await d.job('sync');
|
|
expect(seen).toEqual(['RESOURCE_FORBIDDEN']);
|
|
expect(await d.route(0)).toEqual({ status: 200, body: { id: 1 } });
|
|
});
|
|
|
|
it('notify.send strips emojis and enforces the host caps + in-app link rule', async () => {
|
|
const host = createMockHost({ grants: ['notify:send'], actingUserId: 7, trips: { 1: { members: [7] } } });
|
|
await expect(host.ctx.notify.send({ title: '🎉🎉', body: 'x', scope: 'user', targetId: 7 })).rejects.toThrow(/title is required/);
|
|
await expect(host.ctx.notify.send({ title: 't', body: 'x'.repeat(1001), scope: 'user', targetId: 7 })).rejects.toThrow(/max 1000/);
|
|
await expect(host.ctx.notify.send({ title: 't', body: 'b', scope: 'user', targetId: 7, link: 'https://evil.example' })).rejects.toThrow(/in-app path/);
|
|
await host.ctx.notify.send({ title: 'Trip 🎉 ready', body: 'b', scope: 'trip', targetId: 1, link: '/trips/1' });
|
|
expect(host.notifications).toEqual([{ title: 'Trip ready', body: 'b', link: '/trips/1', scope: 'trip', targetId: 1 }]);
|
|
});
|
|
|
|
it('db guards match the host: forbidden statements, tx control, op cap', async () => {
|
|
const { ctx } = createMockHost({ grants: ['db:own'] });
|
|
await expect(ctx.db.query('PRAGMA user_version')).rejects.toThrow(/not allowed/);
|
|
await expect(ctx.db.tx([{ sql: ' COMMIT' }])).rejects.toThrow(/transaction-control/);
|
|
await expect(ctx.db.tx([{ sql: '/* x */ROLLBACK' }])).rejects.toThrow(/transaction-control/);
|
|
await expect(ctx.db.tx(Array.from({ length: 101 }, () => ({ sql: 'SELECT 1' })))).rejects.toThrow(/at most 100/);
|
|
});
|
|
|
|
it('member writes need member_manage + protect the owner; users.getById serves only public fields', async () => {
|
|
const host = createMockHost({
|
|
grants: ['db:write:members', 'db:read:users'],
|
|
actingUserId: 42,
|
|
users: { 7: { id: 7, username: 'ada', email: 'secret@example.com' }, 42: { id: 42, username: 'me' } },
|
|
trips: { 1: { members: [42, 7], data: { id: 1, user_id: 42 } }, 2: { members: [42], can: { member_manage: false } } },
|
|
});
|
|
await expect(host.ctx.trips.removeMember(1, 42)).rejects.toThrow(/trip owner/);
|
|
await expect(host.ctx.trips.addMember(2, 7)).rejects.toThrow(/RESOURCE_FORBIDDEN/);
|
|
await expect(host.ctx.trips.addMember(1, 999)).rejects.toThrow(/no user 999/);
|
|
const u = await host.ctx.users.getById(7) as Record<string, unknown>;
|
|
expect(u).toMatchObject({ id: 7, username: 'ada' });
|
|
expect('email' in u).toBe(false);
|
|
await expect(host.ctx.users.getById(999)).rejects.toThrow(/no access to user 999/); // no shared trip
|
|
});
|
|
|
|
it('addon toggles gate journal/vacay/collections like production; atlas normalizes codes', async () => {
|
|
const off = createMockHost({
|
|
grants: ['db:read:journal', 'db:read:atlas', 'db:write:atlas', 'db:read:vacay', 'db:read:collections'],
|
|
actingUserId: 1,
|
|
journeyAddonEnabled: false, vacayAddonEnabled: false, collectionsAddonEnabled: false,
|
|
});
|
|
await expect(off.ctx.journal.listMine()).rejects.toThrow(/journey addon is disabled/);
|
|
await expect(off.ctx.vacay.mine()).rejects.toThrow(/vacay addon is disabled/);
|
|
await expect(off.ctx.collections.listMine()).rejects.toThrow(/collections addon is disabled/);
|
|
expect(await off.ctx.atlas.markCountry('de')).toEqual({ visited: true }); // atlas stays enabled
|
|
expect(await off.ctx.atlas.visited()).toMatchObject({ countries: ['DE'] });
|
|
await expect(off.ctx.atlas.markCountry('not-a-code!')).rejects.toThrow(/short code/);
|
|
});
|
|
|
|
it('scheduler refuses a non-finite or >1-year dueAt, like the host', async () => {
|
|
const { ctx } = createMockHost({ grants: ['jobs:run'] });
|
|
await expect(ctx.scheduler.at(Number.NaN, 'x')).rejects.toThrow(/dueAt out of range/);
|
|
await expect(ctx.scheduler.at(Date.now() + 400 * 24 * 3600 * 1000, 'x')).rejects.toThrow(/dueAt out of range/);
|
|
await expect(ctx.scheduler.every(1000, 'x')).rejects.toThrow(/>= 60000/);
|
|
});
|
|
|
|
it('declaredEmits drops an undeclared emit with a warning — production refuses it', () => {
|
|
const host = createMockHost({ declaredEmits: ['rate.updated'] });
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
try {
|
|
host.ctx.events.emit('rate.updated', { a: 1 });
|
|
host.ctx.events.emit('undeclared.event', {});
|
|
expect(warn).toHaveBeenCalledOnce();
|
|
} finally { warn.mockRestore(); }
|
|
expect(host.emitted).toEqual([{ name: 'rate.updated', payload: { a: 1 } }]);
|
|
});
|
|
});
|
|
|
|
describe('scaffold + validate CLIs', () => {
|
|
let tmp: string;
|
|
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-')); });
|
|
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
|
|
|
/**
|
|
* A scaffold is HONESTLY INCOMPLETE: it builds and runs, but it is not publishable until it is
|
|
* documented. These two facts are the contract, and they are deliberately different:
|
|
*
|
|
* pack/dev work immediately — you can install it into a local TREK and try it.
|
|
* validate fails until the README is written and a screenshot exists.
|
|
*
|
|
* It used to be the other way round: a fresh scaffold passed `validate` (which only checked a
|
|
* fifth of the registry's rules and demoted the README to a warning), and the author learned
|
|
* the truth from CI — after cutting an immutable release. A green that means nothing is worse
|
|
* than a red that tells you what to do.
|
|
*/
|
|
it('scaffolds a widget that builds immediately but is not yet publishable', () => {
|
|
scaffold('my-widget', 'widget', tmp);
|
|
const dir = path.join(tmp, 'my-widget');
|
|
expect(fs.existsSync(path.join(dir, 'trek-plugin.json'))).toBe(true);
|
|
expect(fs.existsSync(path.join(dir, 'server', 'index.js'))).toBe(true);
|
|
expect(fs.existsSync(path.join(dir, 'client', 'index.html'))).toBe(true);
|
|
|
|
// It packs — the dev loop is not blocked by the docs gates.
|
|
expect(() => packPluginDir(dir, path.join(tmp, 'w.zip'))).not.toThrow();
|
|
|
|
// But it does not publish, and it says exactly why. Both of these used to be WARNINGS, which
|
|
// is how a scaffold could sail through `validate` and then be rejected by the registry.
|
|
const r = validatePluginDir(dir);
|
|
expect(r.ok).toBe(false);
|
|
expect(r.errors.some((e) => /placeholder/.test(e))).toBe(true);
|
|
// The scaffold's README references ./docs/screenshot.png and never creates it. The old check
|
|
// regexed for an image LINK and passed; this one resolves the path.
|
|
expect(r.errors.some((e) => /screenshot/.test(e))).toBe(true);
|
|
expect(fs.existsSync(path.join(dir, 'docs', 'screenshot.png'))).toBe(false);
|
|
});
|
|
|
|
it('a documented scaffold passes validate clean — the scaffold is not born unfixable', () => {
|
|
scaffold('done-plug', 'widget', tmp);
|
|
const dir = path.join(tmp, 'done-plug');
|
|
makePublishable(dir);
|
|
|
|
const r = validatePluginDir(dir);
|
|
expect(r.errors).toEqual([]);
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
/**
|
|
* The registry REJECTS an icon lucide does not have (validate-entry.mjs: "is not a lucide icon
|
|
* name"), so warning about it locally was a false green: the author saw a pass, cut the release,
|
|
* and CI failed them once the artifact was immutable. Local severity now matches the registry's.
|
|
*/
|
|
it('rejects an icon lucide does not know — the registry does, so we must too', () => {
|
|
scaffold('icon-plug', 'widget', tmp);
|
|
const dir = path.join(tmp, 'icon-plug');
|
|
makePublishable(dir);
|
|
const file = path.join(dir, 'trek-plugin.json');
|
|
const m = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
fs.writeFileSync(file, JSON.stringify({ ...m, icon: 'Stethscope' }, null, 2));
|
|
|
|
const r = validatePluginDir(dir);
|
|
expect(r.ok).toBe(false);
|
|
expect(r.errors.some((e) => /Stethscope.*not a lucide icon/.test(e))).toBe(true);
|
|
});
|
|
|
|
it('accepts any real lucide icon name, not just a curated few', () => {
|
|
scaffold('icon-plug', 'widget', tmp);
|
|
const dir = path.join(tmp, 'icon-plug');
|
|
makePublishable(dir);
|
|
const file = path.join(dir, 'trek-plugin.json');
|
|
const m = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
fs.writeFileSync(file, JSON.stringify({ ...m, icon: 'Stethoscope' }, null, 2));
|
|
|
|
const r = validatePluginDir(dir);
|
|
expect(r.ok).toBe(true);
|
|
expect(r.errors.some((e) => /lucide icon/.test(e))).toBe(false);
|
|
});
|
|
|
|
it('scaffolds a client that opts into the design kit via the marker (source stays one line)', () => {
|
|
scaffold('kit-plug', 'widget', tmp);
|
|
const html = fs.readFileSync(path.join(tmp, 'kit-plug', 'client', 'index.html'), 'utf8');
|
|
expect(html).toContain('<!-- trek:ui -->'); // the one-line opt-in
|
|
expect(html).toContain('trek.onContext'); // uses the bridge the marker installs
|
|
expect(html).not.toContain('--glass-bg'); // kit is NOT pre-inlined in the source
|
|
});
|
|
|
|
it('scaffolds a trip-page plugin (a tab inside the trip) with a client UI', () => {
|
|
scaffold('trip-diary', 'trip-page', tmp);
|
|
const dir = path.join(tmp, 'trip-diary');
|
|
const m = JSON.parse(fs.readFileSync(path.join(dir, 'trek-plugin.json'), 'utf8'));
|
|
expect(m.type).toBe('trip-page');
|
|
expect(fs.existsSync(path.join(dir, 'client', 'index.html'))).toBe(true); // non-integration → gets a UI
|
|
makePublishable(dir);
|
|
expect(validatePluginDir(dir).ok).toBe(true);
|
|
});
|
|
|
|
it('pack expands the design-kit marker into the archived client HTML', () => {
|
|
scaffold('kit-plug', 'widget', tmp);
|
|
const out = path.join(tmp, 'kit-plug.zip');
|
|
packPluginDir(path.join(tmp, 'kit-plug'), out);
|
|
const html = readZip(fs.readFileSync(out))['client/index.html'].toString('utf8');
|
|
expect(html).not.toContain('<!-- trek:ui -->'); // expanded away
|
|
expect(html).toContain('.trek-glass'); // kit CSS inlined
|
|
expect(html).toContain('window.trek'); // bridge inlined
|
|
});
|
|
|
|
it('scaffolds a CommonJS package.json with the SDK as a devDependency only', () => {
|
|
scaffold('my-widget', 'widget', tmp);
|
|
const pkg = JSON.parse(fs.readFileSync(path.join(tmp, 'my-widget', 'package.json'), 'utf8'));
|
|
expect(pkg.type).toBe('commonjs');
|
|
expect(pkg.private).toBe(true);
|
|
expect(pkg.devDependencies['trek-plugin-sdk']).toMatch(/^\^\d/);
|
|
expect(pkg.dependencies).toBeUndefined(); // runtime deps are the author's call; the SDK never is one
|
|
});
|
|
|
|
it('applies author, description, and permissions from options', () => {
|
|
scaffold('opt-plug', 'integration', tmp, { author: 'Jane', description: 'Does X', permissions: ['db:own', 'db:read:trips'] });
|
|
const m = JSON.parse(fs.readFileSync(path.join(tmp, 'opt-plug', 'trek-plugin.json'), 'utf8'));
|
|
expect(m.author).toBe('Jane');
|
|
expect(m.description).toBe('Does X');
|
|
expect(m.permissions).toEqual(['db:own', 'db:read:trips']);
|
|
});
|
|
|
|
it('rejects an invalid plugin name', () => {
|
|
expect(() => scaffold('Bad Name', 'widget', tmp)).toThrow(/invalid plugin id/);
|
|
});
|
|
|
|
it('tolerates a UTF-8 BOM in trek-plugin.json (Windows editors add one)', () => {
|
|
scaffold('bom-plug', 'integration', tmp);
|
|
const dir = path.join(tmp, 'bom-plug');
|
|
makePublishable(dir);
|
|
const mp = path.join(dir, 'trek-plugin.json');
|
|
fs.writeFileSync(mp, '\uFEFF' + fs.readFileSync(mp, 'utf8'));
|
|
|
|
// A bare JSON.parse chokes on the BOM and reports "Unexpected token" against an invisible
|
|
// character \u2014 the manifest must still parse, and the plugin must still be publishable.
|
|
const r = validatePluginDir(dir);
|
|
expect(r.errors.some((e) => /not valid JSON/.test(e))).toBe(false);
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it('validatePluginDir flags a missing manifest', () => {
|
|
expect(validatePluginDir(tmp).ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('dev-server SDK injection', () => {
|
|
let tmp: string;
|
|
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'inject-')); });
|
|
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
|
|
|
it('makes require(trek-plugin-sdk) resolve from the package itself — no npm install', async () => {
|
|
const { installSdkInjection } = await import('../src/cli/dev.js');
|
|
const { createRequire } = await import('node:module');
|
|
installSdkInjection();
|
|
// A scaffold-shaped CJS entry that requires the SDK without any node_modules.
|
|
// The injected surface must MATCH the prod child shim: definePlugin +
|
|
// PLUGIN_API_VERSION only, subpaths throw the same pointed error.
|
|
fs.writeFileSync(path.join(tmp, 'entry.cjs'),
|
|
"const sdkShim = require('trek-plugin-sdk');\n" +
|
|
"let testingError = '';\n" +
|
|
"try { require('trek-plugin-sdk/testing'); } catch (e) { testingError = e.message; }\n" +
|
|
'module.exports = { def: sdkShim.definePlugin({ routes: [] }), api: sdkShim.PLUGIN_API_VERSION, keys: Object.keys(sdkShim).sort(), testingError };\n');
|
|
const req = createRequire(path.join(tmp, 'entry.cjs'));
|
|
const mod = req(path.join(tmp, 'entry.cjs')) as { def: unknown; api: number; keys: string[]; testingError: string };
|
|
expect(mod.def).toEqual({ routes: [] });
|
|
expect(mod.api).toBe(1);
|
|
expect(mod.keys).toEqual(['PLUGIN_API_VERSION', 'definePlugin']); // prod parity — no dev-only extras
|
|
expect(mod.testingError).toMatch(/build\/test-time/);
|
|
});
|
|
});
|
|
|
|
describe('dev-server preview', () => {
|
|
// The preview plays the host, so it has to refuse what the host refuses. A preview
|
|
// that answered trek:geolocation without the grant green-lights a plugin production
|
|
// then breaks — the one drift direction this package cannot afford.
|
|
it('gates the geolocation bridge on geolocation:read, like PluginFrame', async () => {
|
|
const { preview } = await import('../src/cli/dev.js');
|
|
const without = preview('demo', 'widget', 1, {});
|
|
expect(without).toContain('var GEO_ALLOWED=false;');
|
|
expect(without).toContain('if(!GEO_ALLOWED){');
|
|
expect(without).toContain('error:"forbidden"');
|
|
|
|
expect(preview('demo', 'widget', 1, { geoAllowed: true })).toContain('var GEO_ALLOWED=true;');
|
|
});
|
|
});
|
|
|
|
describe('dev db bind shapes', () => {
|
|
let tmp: string;
|
|
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'devdb-')); });
|
|
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
|
|
|
// Without node:sqlite (Node < 22.5) createDevDb falls back to the [] stub and
|
|
// there is no bind behaviour to test.
|
|
const hasNodeSqlite = (() => {
|
|
try { createRequire(import.meta.url)('node:sqlite'); return true; } catch { return false; }
|
|
})();
|
|
|
|
it.runIf(hasNodeSqlite)('accepts spread args AND a single array of them, like the real host', async () => {
|
|
const { createDevDb } = await import('../src/cli/dev.js');
|
|
const { db, close } = createDevDb(path.join(tmp, 'db.sqlite'));
|
|
try {
|
|
await db.migrate('001', 'CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT)');
|
|
await db.exec('INSERT INTO kv (k, v) VALUES (?, ?)', ['a', '1']); // array form (better-sqlite3 style)
|
|
await db.exec('INSERT INTO kv (k, v) VALUES (?, ?)', 'b', '2'); // spread form
|
|
expect(await db.query('SELECT v FROM kv WHERE k = ?', ['a'])).toEqual([{ v: '1' }]);
|
|
expect(await db.query('SELECT v FROM kv WHERE k = ?', 'b')).toEqual([{ v: '2' }]);
|
|
} finally {
|
|
close();
|
|
}
|
|
});
|
|
|
|
it.runIf(hasNodeSqlite)('refuses the statements the real host forbids for plugin databases', async () => {
|
|
const { createDevDb } = await import('../src/cli/dev.js');
|
|
const { db, close } = createDevDb(path.join(tmp, 'db.sqlite'));
|
|
try {
|
|
await expect(db.query('PRAGMA user_version')).rejects.toThrow(/not allowed/);
|
|
await expect(db.exec("ATTACH DATABASE 'x' AS y")).rejects.toThrow(/not allowed/);
|
|
await expect(db.migrate('001', 'PRAGMA journal_mode = OFF')).rejects.toThrow(/not allowed/);
|
|
} finally {
|
|
close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('reference plugin (examples/koffi)', () => {
|
|
const dir = path.resolve(import.meta.dirname, '..', 'examples', 'koffi');
|
|
|
|
it('passes the same validation authors run', () => {
|
|
const r = validatePluginDir(dir);
|
|
expect(r.errors).toEqual([]);
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it('has a valid, minimal-permission hero-widget manifest', () => {
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'trek-plugin.json'), 'utf8')) as {
|
|
capabilities?: { widget?: { slot?: string } };
|
|
};
|
|
const res = validateManifest(manifest);
|
|
expect(res.ok).toBe(true);
|
|
expect(res.manifest?.permissions).toEqual(['db:read:trips']);
|
|
expect(manifest.capabilities?.widget?.slot).toBe('hero');
|
|
});
|
|
});
|
|
|
|
describe('makeZip', () => {
|
|
it('round-trips through a central-directory reader (installer-compatible)', () => {
|
|
const files = [
|
|
{ name: 'trek-plugin.json', data: Buffer.from('{"id":"x"}') },
|
|
{ name: 'server/index.js', data: Buffer.from('module.exports={}\n'.repeat(200)) },
|
|
];
|
|
const zip = makeZip(files);
|
|
expect(zip.subarray(0, 2).toString()).toBe('PK'); // local file header magic
|
|
const back = readZip(zip);
|
|
expect(Object.keys(back).sort()).toEqual(['server/index.js', 'trek-plugin.json']);
|
|
expect(back['trek-plugin.json'].toString()).toBe('{"id":"x"}');
|
|
expect(back['server/index.js'].length).toBe(files[1].data.length);
|
|
});
|
|
});
|
|
|
|
describe('pack + entry (publishing automation)', () => {
|
|
const koffi = path.resolve(import.meta.dirname, '..', 'examples', 'koffi');
|
|
let tmp: string;
|
|
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-')); });
|
|
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
|
|
|
it('packs the canonical layout, excludes docs/, and reports sha256 + size', () => {
|
|
const out = path.join(tmp, 'plugin.zip');
|
|
const r = packPluginDir(koffi, out);
|
|
expect(r.files).toEqual(['README.md', 'client/index.html', 'server/index.js', 'trek-plugin.json']);
|
|
expect(r.sha256).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(r.size).toBeGreaterThan(0);
|
|
const back = readZip(fs.readFileSync(out));
|
|
expect(back['trek-plugin.json']).toBeTruthy();
|
|
expect(Object.keys(back).some((n) => n.startsWith('docs/'))).toBe(false); // screenshot served from repo, not shipped
|
|
});
|
|
|
|
it('refuses a plugin that ships a native binary', () => {
|
|
const bad = path.join(tmp, 'bad');
|
|
fs.mkdirSync(path.join(bad, 'server'), { recursive: true });
|
|
fs.writeFileSync(path.join(bad, 'trek-plugin.json'), JSON.stringify({ id: 'bad-plug', name: 'Bad', version: '1.0.0', type: 'integration', permissions: [], egress: [], trek: '>=3.2.0 <4.0.0' }));
|
|
fs.writeFileSync(path.join(bad, 'server', 'index.js'), 'module.exports={}');
|
|
fs.writeFileSync(path.join(bad, 'server', 'thing.node'), Buffer.from([1, 2, 3]));
|
|
fs.writeFileSync(path.join(bad, 'README.md'), '# Bad\n\ncontent');
|
|
expect(() => packPluginDir(bad, path.join(tmp, 'x.zip'))).toThrow(/native binaries/);
|
|
});
|
|
|
|
it('builds a registry entry with sha256/size/commit/trek filled in', () => {
|
|
const out = path.join(tmp, 'plugin.zip');
|
|
const packed = packPluginDir(koffi, out);
|
|
const entry = buildEntry({
|
|
dir: koffi, repo: 'mauriceboe/TREK-plugin-koffi', tag: 'v1.0.0', zipPath: out,
|
|
commit: 'a'.repeat(40), now: '2026-07-04T00:00:00.000Z',
|
|
});
|
|
expect(entry.id).toBe('koffi');
|
|
expect(entry.type).toBe('widget');
|
|
const v = entry.versions[0];
|
|
expect(v.sha256).toBe(packed.sha256);
|
|
expect(v.size).toBe(packed.size);
|
|
expect(v.commitSha).toBe('a'.repeat(40));
|
|
// The RAW range is the one compatibility field an entry carries. minTrekVersion is gone:
|
|
// it restated the range's lower bound in a weaker form (it cannot express the exclusive
|
|
// upper bound), so the entry used to drop "<4.0.0" silently and a TREK 4 server read
|
|
// "requires 3.2.0+" and considered the plugin compatible.
|
|
expect(v.trek).toBe('>=3.2.0 <4.0.0');
|
|
expect(v).not.toHaveProperty('minTrekVersion');
|
|
expect(v.downloadUrl).toBe('https://github.com/mauriceboe/TREK-plugin-koffi/releases/download/v1.0.0/plugin.zip');
|
|
expect(v.nativeModules).toBe(false);
|
|
});
|
|
|
|
// The store tile reads `icon` off the registry ENTRY (the index has no manifest to consult),
|
|
// so if the entry generator drops it, every plugin in the store renders as a generic Blocks.
|
|
it('carries the manifest icon onto the registry entry', () => {
|
|
const out = path.join(tmp, 'plugin.zip');
|
|
packPluginDir(koffi, out);
|
|
const entry = buildEntry({
|
|
dir: koffi, repo: 'mauriceboe/TREK-plugin-koffi', tag: 'v1.0.0', zipPath: out,
|
|
commit: 'a'.repeat(40), now: '2026-07-04T00:00:00.000Z',
|
|
});
|
|
expect(entry.icon).toBe('Luggage'); // koffi's trek-plugin.json declares it
|
|
});
|
|
|
|
it('--merge refreshes the icon from the manifest, but never wipes one the entry already carries', () => {
|
|
const out = path.join(tmp, 'plugin.zip');
|
|
packPluginDir(koffi, out);
|
|
const prior = (icon: string) => {
|
|
const p = path.join(tmp, `prior-${icon}.json`);
|
|
fs.writeFileSync(p, JSON.stringify({
|
|
id: 'koffi', name: 'Koffi', author: 'TREK', description: 'x',
|
|
repo: 'mauriceboe/TREK-plugin-koffi', type: 'widget', icon,
|
|
versions: [{ version: '0.9.0', gitTag: 'v0.9.0', commitSha: 'b'.repeat(40), downloadUrl: 'https://x/y.zip', sha256: 'c'.repeat(64), size: 10, apiVersion: 1, nativeModules: false, publishedAt: '2026-01-01T00:00:00.000Z' }],
|
|
}));
|
|
return p;
|
|
};
|
|
|
|
// koffi's manifest declares Luggage → an author who rebrands sees it in the store
|
|
const rebranded = buildEntry({
|
|
dir: koffi, repo: 'mauriceboe/TREK-plugin-koffi', tag: 'v1.0.0', zipPath: out,
|
|
commit: 'a'.repeat(40), now: '2026-07-04T00:00:00.000Z', mergePath: prior('Coffee'),
|
|
});
|
|
expect(rebranded.icon).toBe('Luggage');
|
|
|
|
// a manifest that declares NO icon leaves the entry's existing one alone
|
|
const noIconOpts = entryOptsFor('no-icon', '>=3.2.0 <4.0.0');
|
|
const keepPath = path.join(tmp, 'keep.json');
|
|
fs.writeFileSync(keepPath, JSON.stringify({
|
|
id: 'no-icon', name: 'No Icon', author: 'TREK', description: 'x', repo: 'a/b',
|
|
type: 'integration', icon: 'Coffee', versions: [],
|
|
}));
|
|
const kept = buildEntry({ ...noIconOpts, now: '2026-07-04T00:00:00.000Z', mergePath: keepPath });
|
|
expect(kept.icon).toBe('Coffee');
|
|
});
|
|
|
|
it('publishes an upper-bound-only range verbatim, with no floor to get wrong', () => {
|
|
// This is why the derived floor is gone. "<4.0.0" has a first X.Y.Z of 4.0.0 but a lower
|
|
// bound of 0.0.0, and the old regex published 4.0.0 as the MINIMUM — advertising a plugin
|
|
// that supports everything BELOW 4 as requiring 4+, the precise inverse of what its author
|
|
// wrote. A field that only ever restates the range cannot be wrong if it does not exist.
|
|
const entry = buildEntry({ ...entryOptsFor('upper-only', '<4.0.0'), now: '2026-07-04T00:00:00.000Z' });
|
|
expect(entry.versions[0].trek).toBe('<4.0.0');
|
|
expect(entry.versions[0]).not.toHaveProperty('minTrekVersion');
|
|
});
|
|
|
|
it('refuses to publish a plugin with no usable trek range', () => {
|
|
expect(() => buildEntry({ ...entryOptsFor('no-trek', undefined), now: '2026-07-04T00:00:00.000Z' }))
|
|
.toThrow(/no valid "trek" version range/);
|
|
expect(() => buildEntry({ ...entryOptsFor('bad-trek', '>=4.0.0 <3.0.0'), now: '2026-07-04T00:00:00.000Z' }))
|
|
.toThrow(/no valid "trek" version range/);
|
|
});
|
|
|
|
/** A minimal plugin dir + artifact for buildEntry (which only needs the zip to exist). */
|
|
function entryOptsFor(id: string, trek: string | undefined) {
|
|
const dir = path.join(tmp, id);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'trek-plugin.json'), JSON.stringify({ id, name: id, version: '1.0.0', type: 'integration', ...(trek === undefined ? {} : { trek }) }));
|
|
const zipPath = path.join(tmp, `${id}.zip`);
|
|
fs.writeFileSync(zipPath, makeZip([{ name: 'trek-plugin.json', data: Buffer.from('{}') }]));
|
|
return { dir, repo: 'a/b', tag: 'v1.0.0', zipPath, commit: 'a'.repeat(40) };
|
|
}
|
|
|
|
it('--merge prepends the new version onto an existing entry, newest-first', () => {
|
|
const out = path.join(tmp, 'plugin.zip');
|
|
packPluginDir(koffi, out);
|
|
const existingPath = path.join(tmp, 'koffi.json');
|
|
fs.writeFileSync(existingPath, JSON.stringify({
|
|
id: 'koffi', name: 'Koffi', author: 'TREK', description: 'x', repo: 'mauriceboe/TREK-plugin-koffi', type: 'widget',
|
|
versions: [{ version: '0.9.0', gitTag: 'v0.9.0', commitSha: 'b'.repeat(40), downloadUrl: 'https://github.com/x/y/releases/download/v0.9.0/plugin.zip', sha256: 'c'.repeat(64), minTrekVersion: '3.2.0', size: 10, apiVersion: 1, nativeModules: false, publishedAt: '2026-01-01T00:00:00.000Z' }],
|
|
}));
|
|
const merged = buildEntry({
|
|
dir: koffi, repo: 'mauriceboe/TREK-plugin-koffi', tag: 'v1.0.0', zipPath: out,
|
|
commit: 'a'.repeat(40), mergePath: existingPath, now: '2026-07-04T00:00:00.000Z',
|
|
});
|
|
expect(merged.versions.map((v) => v.version)).toEqual(['1.0.0', '0.9.0']);
|
|
});
|
|
});
|
|
|
|
describe('listZipNames', () => {
|
|
it('lists central-directory entries of a makeZip archive', () => {
|
|
const zip = makeZip([{ name: 'a.js', data: Buffer.from('x') }, { name: 'server/b.js', data: Buffer.from('y'.repeat(100)) }]);
|
|
expect(listZipNames(zip).sort()).toEqual(['a.js', 'server/b.js']);
|
|
});
|
|
});
|
|
|
|
describe('sign + keygen (author signatures, TOFU)', () => {
|
|
const koffi = path.resolve(import.meta.dirname, '..', 'examples', 'koffi');
|
|
let tmp: string;
|
|
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sign-')); });
|
|
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
|
|
|
it('keygen writes a private key and returns a schema-length public key', () => {
|
|
const keyPath = path.join(tmp, 'k.pem');
|
|
const { publicKey } = generateKeypair(keyPath);
|
|
expect(fs.existsSync(keyPath)).toBe(true);
|
|
expect(publicKey.length).toBeGreaterThanOrEqual(40); // registry schema minLength
|
|
expect(() => generateKeypair(keyPath)).toThrow(/already exists/); // never clobbers a key
|
|
});
|
|
|
|
it('a signature round-trips through the server-shaped verifier', () => {
|
|
const keyPath = path.join(tmp, 'k.pem');
|
|
generateKeypair(keyPath);
|
|
const key = loadPrivateKey(keyPath);
|
|
const bytes = Buffer.from('the exact plugin.zip bytes ' + 'x'.repeat(60));
|
|
const sig = signArtifact(bytes, key);
|
|
expect(sig.length).toBeGreaterThanOrEqual(40);
|
|
expect(verifyArtifact(bytes, sig, publicKeyBase64(key))).toBe(true);
|
|
expect(verifyArtifact(Buffer.concat([bytes, Buffer.from('!')]), sig, publicKeyBase64(key))).toBe(false);
|
|
});
|
|
|
|
it('entry --sign fills signature + authorPublicKey that verify against the artifact', () => {
|
|
const out = path.join(tmp, 'plugin.zip');
|
|
packPluginDir(koffi, out);
|
|
const keyPath = path.join(tmp, 'k.pem');
|
|
const { publicKey } = generateKeypair(keyPath);
|
|
const entry = buildEntry({ dir: koffi, repo: 'mauriceboe/TREK-plugin-koffi', tag: 'v1.0.0', zipPath: out, commit: 'a'.repeat(40), signKeyPath: keyPath, now: '2026-07-04T00:00:00.000Z' });
|
|
expect(entry.authorPublicKey).toBe(publicKey);
|
|
const sig = entry.versions[0].signature;
|
|
expect(sig).toBeTruthy();
|
|
expect(verifyArtifact(fs.readFileSync(out), sig!, entry.authorPublicKey!)).toBe(true);
|
|
});
|
|
|
|
it('refuses to sign an update with a different key than the one already published', () => {
|
|
const out = path.join(tmp, 'plugin.zip');
|
|
packPluginDir(koffi, out);
|
|
const key1 = path.join(tmp, 'k1.pem');
|
|
generateKeypair(key1);
|
|
const existing = buildEntry({ dir: koffi, repo: 'mauriceboe/TREK-plugin-koffi', tag: 'v1.0.0', zipPath: out, commit: 'a'.repeat(40), signKeyPath: key1, now: '2026-07-04T00:00:00.000Z' });
|
|
const existingPath = path.join(tmp, 'koffi.json');
|
|
fs.writeFileSync(existingPath, JSON.stringify({ ...existing, versions: existing.versions.map((v) => ({ ...v, version: '0.9.0', gitTag: 'v0.9.0' })) }));
|
|
const key2 = path.join(tmp, 'k2.pem');
|
|
generateKeypair(key2);
|
|
expect(() => buildEntry({ dir: koffi, repo: 'mauriceboe/TREK-plugin-koffi', tag: 'v1.1.0', zipPath: out, commit: 'a'.repeat(40), mergePath: existingPath, signKeyPath: key2, now: '2026-07-04T00:00:00.000Z' })).toThrow(/differs from the one already published/);
|
|
});
|
|
});
|
|
|
|
describe('mock-host inter-plugin (plugins.call + events.emit)', () => {
|
|
it('calls a configured dependency export and records the call', async () => {
|
|
const { ctx, calls } = createMockHost({
|
|
pluginExports: { 'dep-lib': { greet: (args: any) => ({ hi: args?.who }) } },
|
|
});
|
|
const r = await ctx.plugins.call('dep-lib', 'greet', { who: 'ada' });
|
|
expect(r).toEqual({ hi: 'ada' });
|
|
expect(calls.some((c) => c.method === 'plugins.call')).toBe(true);
|
|
});
|
|
|
|
it('throws RESOURCE_FORBIDDEN calling an export that is not configured', async () => {
|
|
const { ctx } = createMockHost({ pluginExports: { 'dep-lib': {} } });
|
|
await expect(ctx.plugins.call('dep-lib', 'nope', {})).rejects.toThrow(/RESOURCE_FORBIDDEN/);
|
|
await expect(ctx.plugins.call('other', 'greet', {})).rejects.toThrow(/RESOURCE_FORBIDDEN/);
|
|
});
|
|
|
|
it('records emitted events', () => {
|
|
const { ctx, emitted } = createMockHost({});
|
|
ctx.events.emit('rate.updated', { pair: 'USD/EUR' });
|
|
expect(emitted).toEqual([{ name: 'rate.updated', payload: { pair: 'USD/EUR' } }]);
|
|
});
|
|
|
|
it('drives a notificationChannel USERLESS, with the recipient config passed in', async () => {
|
|
const sent: unknown[] = [];
|
|
const def = definePlugin({
|
|
hooks: {
|
|
notificationChannel: {
|
|
async send(msg, config, ctx) {
|
|
// The whole point of the userless dispatch: the credentials arrive as `config`,
|
|
// and the channel canNOT read anything as the recipient.
|
|
sent.push({ msg, token: config.token, settings: await ctx.settings.get('token') });
|
|
},
|
|
async test(config) { if (!config.token) throw new Error('no token'); },
|
|
},
|
|
},
|
|
});
|
|
const host = createMockHost({
|
|
grants: ['hook:notification-channel', 'db:read:trips'],
|
|
actingUserId: 7,
|
|
userSettings: { token: 'abc' },
|
|
trips: { 1: { members: [7] } },
|
|
});
|
|
const d = host.run(def);
|
|
|
|
await d.channel.send({ event: 'trip_invite', title: 'Hi', body: 'Japan' });
|
|
expect(sent).toEqual([{
|
|
msg: { event: 'trip_invite', title: 'Hi', body: 'Japan' },
|
|
token: 'abc',
|
|
// Userless: ctx.settings.get() resolves against the ACTING user, and there is none.
|
|
settings: undefined,
|
|
}]);
|
|
|
|
await expect(d.channel.test({ token: 'x' })).resolves.toBeUndefined();
|
|
await expect(d.channel.test({})).rejects.toThrow(/no token/);
|
|
});
|
|
|
|
it('refuses a channel event the host would never dispatch, and a manifest may narrow the set', async () => {
|
|
const def = definePlugin({ hooks: { notificationChannel: { async send() { /* delivered */ } } } });
|
|
const d = createMockHost({ grants: ['hook:notification-channel'] }).run(def);
|
|
// Admin-scoped / in-app-only events are not in CHANNEL_EVENTS.
|
|
await expect(d.channel.send({ event: 'admin_alert', title: 'T', body: 'B' })).rejects.toThrow(/never dispatches/);
|
|
await expect(d.channel.send({ event: 'trip_invite', title: 'T', body: 'B' })).resolves.toBeUndefined();
|
|
|
|
const narrowed = createMockHost({ grants: ['hook:notification-channel'], channelEvents: ['todo_due'] }).run(def);
|
|
await expect(narrowed.channel.send({ event: 'trip_invite', title: 'T', body: 'B' })).rejects.toThrow(/never dispatches/);
|
|
await expect(narrowed.channel.send({ event: 'todo_due', title: 'T', body: 'B' })).resolves.toBeUndefined();
|
|
|
|
await expect(createMockHost({ grants: ['hook:notification-channel'] }).run(definePlugin({}))
|
|
.channel.send({ event: 'todo_due', title: 'T', body: 'B' }))
|
|
.rejects.toThrow(/no notificationChannel hook/);
|
|
});
|
|
|
|
it('a plugin with no notificationChannel.test is a clear error, not a silent pass', async () => {
|
|
const def = definePlugin({ hooks: { notificationChannel: { async send() {} } } });
|
|
await expect(createMockHost({ grants: ['hook:notification-channel'] }).run(def).channel.test())
|
|
.rejects.toThrow(/no notificationChannel\.test/);
|
|
});
|
|
|
|
it('runs a settings action as the CLICKING user and shapes the result like the host', async () => {
|
|
const def = definePlugin({
|
|
actions: {
|
|
test: async (ctx) => ({ ok: true, message: `token=${await ctx.settings.get('token')} ✅` }),
|
|
quiet: () => {},
|
|
boom: () => { throw new Error('credentials rejected'); },
|
|
loud: () => ({ ok: false, message: 'x'.repeat(500) }),
|
|
},
|
|
});
|
|
const host = createMockHost({ actingUserId: 7, userSettings: { token: 'abc' } });
|
|
const d = host.run(def);
|
|
|
|
// User-initiated: ctx.settings.get() returns the clicker's own value. The message is
|
|
// emoji-stripped and bounded, exactly as the host does before showing it.
|
|
expect(await d.action('test')).toEqual({ ok: true, message: 'token=abc' });
|
|
// A handler that returns nothing succeeded.
|
|
expect(await d.action('quiet')).toEqual({ ok: true, message: undefined });
|
|
// Throwing is the documented "action failed" path, not a rejected promise.
|
|
expect(await d.action('boom')).toEqual({ ok: false, message: 'credentials rejected' });
|
|
expect((await d.action('loud')).message).toHaveLength(200);
|
|
|
|
await expect(d.action('nope')).rejects.toThrow(/no action/);
|
|
});
|
|
|
|
it('refuses an action key the manifest does not declare', async () => {
|
|
const def = definePlugin({ actions: { sync: () => ({ ok: true }) } });
|
|
const d = createMockHost({ actingUserId: 7, declaredActions: ['test'] }).run(def);
|
|
await expect(d.action('sync')).rejects.toThrow(/RESOURCE_FORBIDDEN/);
|
|
});
|
|
|
|
it('accepts scoped declaredActions entries and drives an instance action with the acting-user ctx', async () => {
|
|
const def = definePlugin({
|
|
actions: {
|
|
purge: async (ctx) => ({ ok: true, message: `url=${String(ctx.config.url)} user=${await ctx.settings.get('token')}` }),
|
|
sync: () => ({ ok: true }),
|
|
},
|
|
});
|
|
const d = createMockHost({
|
|
actingUserId: 7,
|
|
config: { url: 'https://x.example' },
|
|
userSettings: { token: 'abc' },
|
|
declaredActions: ['sync', { key: 'purge', scope: 'instance' }],
|
|
}).run(def);
|
|
// The clicking admin is the acting user: ctx.config AND their own settings resolve.
|
|
expect(await d.action('purge')).toEqual({ ok: true, message: 'url=https://x.example user=abc' });
|
|
expect(await d.action('sync')).toEqual({ ok: true, message: undefined });
|
|
await expect(d.action('other')).rejects.toThrow(/RESOURCE_FORBIDDEN/);
|
|
});
|
|
|
|
it('validates the scope of a settings action', () => {
|
|
const base = { id: 'plugin', name: 'P', version: '1.0.0', type: 'integration', trek: '>=4.0.0 <5.0.0' };
|
|
expect(validateManifest({ ...base, actions: [{ key: 'a', scope: 'instance' }] }).ok).toBe(true);
|
|
expect(validateManifest({ ...base, actions: [{ key: 'a', scope: 'user' }] }).ok).toBe(true);
|
|
expect(validateManifest({ ...base, actions: [{ key: 'a' }] }).ok).toBe(true); // scope optional
|
|
const bad = validateManifest({ ...base, actions: [{ key: 'a', scope: 'global' }] });
|
|
expect(bad.ok).toBe(false);
|
|
expect(bad.errors).toContain('action "a".scope must be "user" or "instance"');
|
|
// Keys stay unique ACROSS scopes — one action, one form.
|
|
expect(validateManifest({ ...base, actions: [{ key: 'a', scope: 'user' }, { key: 'a', scope: 'instance' }] }).ok).toBe(false);
|
|
});
|
|
|
|
it('never resolves a settings key off Object.prototype', async () => {
|
|
// The bug this models: `__proto__`/`constructor` used to resolve to a truthy object,
|
|
// reporting a REQUIRED field as configured for a user who had configured nothing.
|
|
const { ctx } = createMockHost({ actingUserId: 7, userSettings: { token: 'abc' } });
|
|
expect(await ctx.settings.get('__proto__')).toBeUndefined();
|
|
expect(await ctx.settings.get('constructor')).toBeUndefined();
|
|
expect(await ctx.settings.get('token')).toBe('abc');
|
|
// And a fixture the host would have refused at install fails the test up front. Note a
|
|
// `__proto__` OWN key only exists via JSON.parse (in a literal it sets the prototype) —
|
|
// which is exactly the shape a stored config blob arrives in.
|
|
expect(() => createMockHost({ userSettings: JSON.parse('{"__proto__":{"evil":1}}') })).toThrow(/settings key/);
|
|
expect(() => createMockHost({ userSettings: { '9lives': 1 } })).toThrow(/settings key/);
|
|
});
|
|
});
|
|
|
|
describe('validateManifest capabilities.provides/emits', () => {
|
|
const base = { id: 'my-plug', name: 'My Plug', version: '1.0.0', type: 'integration', permissions: ['db:own'], trek: '>=3.2.0 <4.0.0' };
|
|
it('accepts well-formed provides + emits', () => {
|
|
const r = validateManifest({ ...base, capabilities: { provides: ['computeRate'], emits: ['rate.updated'] } });
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
it('rejects a malformed export/event name and a non-array', () => {
|
|
expect(validateManifest({ ...base, capabilities: { provides: ['bad name!'] } }).ok).toBe(false);
|
|
expect(validateManifest({ ...base, capabilities: { emits: 'nope' } }).ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The `trek` range is what TREK gates installs on, so it has to be caught HERE — the same
|
|
* validateManifest the registry CI runs — rather than by a rejected install.
|
|
*/
|
|
describe('validateManifest: the trek range', () => {
|
|
const base = { id: 'my-plug', name: 'My Plug', version: '1.0.0', type: 'integration', permissions: ['db:own'] };
|
|
|
|
it('requires a range', () => {
|
|
const r = validateManifest({ ...base });
|
|
expect(r.ok).toBe(false);
|
|
expect(r.errors.join()).toMatch(/missing "trek"/);
|
|
});
|
|
|
|
it('rejects a range that is not semver, and one nothing can satisfy', () => {
|
|
expect(validateManifest({ ...base, trek: '3.2+' }).ok).toBe(false);
|
|
// Syntactically valid, semantically empty: no TREK could ever run it.
|
|
expect(validateManifest({ ...base, trek: '>=4.0.0 <3.0.0' }).ok).toBe(false);
|
|
});
|
|
|
|
it('accepts the ranges authors really write, and carries the range through', () => {
|
|
for (const trek of ['>=3.2.0 <4.0.0', '^3.2.0', '>=3']) {
|
|
const r = validateManifest({ ...base, trek });
|
|
expect(r.ok, trek).toBe(true);
|
|
expect(r.manifest?.trek).toBe(trek);
|
|
}
|
|
});
|
|
|
|
it('allows "*" (it is a legal claim) but validate warns — it is nearly always a lie', () => {
|
|
expect(validateManifest({ ...base, trek: '*' }).ok).toBe(true);
|
|
expect(isUnboundedRange('*')).toBe(true);
|
|
expect(isUnboundedRange('>=3.2.0 <4.0.0')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('settingDefaults is part of the package surface', () => {
|
|
it('is exported from the index so a plugin test can seed createMockHost like the host does', async () => {
|
|
const sdk = await import('../src/index.js');
|
|
const manifest = { settings: [{ key: 'a', default: 1 }, { key: 'u', scope: 'user', default: 'x' }] };
|
|
expect(sdk.settingDefaults(manifest, 'instance')).toEqual({ a: 1 });
|
|
expect(sdk.settingDefaults(manifest, 'user')).toEqual({ u: 'x' });
|
|
});
|
|
});
|