- agent_classes/instances/sessions API w/ lifecycle (heartbeat->online->lost->down) - TopDownTavern (3/4 tile RPG scene) + Browse Agents + Booth dashboard + live stats - /api/auth/verify SSO bridge (nginx auth_request -> Gitea reverse-proxy auth) - RPG-themed login across docs/pm; webagent broker login - server/platform-tests.mjs: 41 assertions, all passing Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
117 lines
8.7 KiB
JavaScript
117 lines
8.7 KiB
JavaScript
// amerc platform test suite. Spins up the API on a temp DB and exercises every surface.
|
|
// Run: node --experimental-sqlite server/platform-tests.mjs
|
|
import { spawn } from 'node:child_process';
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
const PORT = 5310, BASE = `http://127.0.0.1:${PORT}/api`;
|
|
const dir = mkdtempSync(join(tmpdir(), 'amerc-test-'));
|
|
const srv = spawn(process.execPath, ['--experimental-sqlite', new URL('./amerc-api.mjs', import.meta.url).pathname], {
|
|
env: { ...process.env, PORT: String(PORT), AMERC_DB: join(dir, 't.db'), AMERC_FILES: join(dir, 'disk'), AMERC_SECRET: 'test-secret' },
|
|
stdio: 'ignore',
|
|
});
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
let pass = 0, fail = 0; const fails = [];
|
|
function ok(cond, name) { if (cond) { pass++; } else { fail++; fails.push(name); console.log(' ✗ ' + name); } }
|
|
function eq(a, b, name) { ok(JSON.stringify(a) === JSON.stringify(b), `${name} (got ${JSON.stringify(a)}, want ${JSON.stringify(b)})`); }
|
|
|
|
let cookies = {};
|
|
function setCookie(res) { const sc = res.headers.get('set-cookie'); if (sc) { const [kv] = sc.split(';'); const [k, v] = kv.split('='); cookies[k] = v; } }
|
|
const cookieHdr = () => Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join('; ');
|
|
async function req(path, { method = 'GET', body, cookie = true, bearer, raw } = {}) {
|
|
const headers = {};
|
|
if (body) headers['Content-Type'] = 'application/json';
|
|
if (cookie && cookieHdr()) headers.Cookie = cookieHdr();
|
|
if (bearer) headers.Authorization = `Bearer ${bearer}`;
|
|
const res = await fetch(BASE + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
|
setCookie(res);
|
|
if (raw) return res;
|
|
let j = {}; try { j = await res.json(); } catch {}
|
|
return { status: res.status, ...j };
|
|
}
|
|
|
|
async function main() {
|
|
// wait for server
|
|
for (let i = 0; i < 30; i++) { try { const r = await fetch(BASE + '/health'); if (r.ok) break; } catch {} await sleep(150); }
|
|
|
|
console.log('\n== auth + SSO ==');
|
|
let r = await req('/health'); ok(r.ok && r.version === 2, 'health v2');
|
|
r = await req('/auth/signup', { method: 'POST', body: { email: 'Admin@x.io', password: 'secret123', handle: 'admin' } });
|
|
ok(r.ok && r.user.role === 'admin', 'first signup -> admin'); eq(r.user.email, 'admin@x.io', 'email lowercased');
|
|
r = await req('/auth/me'); ok(r.user && r.user.handle === 'admin', 'me via cookie');
|
|
r = await req('/auth/verify', { raw: true }); ok(r.status === 200 && r.headers.get('x-amerc-user') === 'admin', 'SSO verify header');
|
|
r = await req('/auth/signup', { method: 'POST', body: { email: 'm@x.io', password: 'short' } }); ok(r.status === 400, 'reject short password');
|
|
const adminCookies = { ...cookies };
|
|
// member
|
|
cookies = {}; r = await req('/auth/signup', { method: 'POST', body: { email: 'mercA@x.io', password: 'hunter22', handle: 'mercA' } });
|
|
ok(r.user.role === 'member', 'second signup -> member');
|
|
r = await req('/admin/users'); ok(r.status === 403, 'member blocked from admin');
|
|
r = await req('/auth/password', { method: 'POST', body: { currentPassword: 'WRONG', newPassword: 'newpass1' } }); ok(r.status === 401, 'pw change wrong current');
|
|
r = await req('/auth/password', { method: 'POST', body: { currentPassword: 'hunter22', newPassword: 'newpass1' } }); ok(r.ok, 'pw change ok');
|
|
r = await req('/auth/login', { method: 'POST', body: { email: 'mercA@x.io', password: 'newpass1' } }); ok(r.ok, 'login with new pw');
|
|
const memberCookies = { ...cookies };
|
|
|
|
console.log('== admin + agent keys ==');
|
|
cookies = adminCookies;
|
|
r = await req('/admin/users'); ok(r.ok && r.users.length === 2, 'admin lists users');
|
|
r = await req('/admin/keys', { method: 'POST', body: { name: 'fleet' } }); const agentKey = r.key; ok(r.ok && agentKey?.startsWith('amk_'), 'mint agent key');
|
|
cookies = {}; r = await req('/auth/me', { bearer: agentKey }); ok(r.agent === 'fleet', 'agent identity via Bearer');
|
|
|
|
console.log('== docs / netdisk / boards (agent + user) ==');
|
|
cookies = {}; r = await req('/docs', { method: 'POST', bearer: agentKey, body: { title: 'Agent Doc', body: '# hi' } }); const docId = r.id; ok(r.ok, 'agent writes doc');
|
|
cookies = adminCookies; r = await req(`/docs/${docId}`); ok(r.doc.updated_by === 'agent:fleet', 'doc authored by agent');
|
|
r = await req('/files', { method: 'POST', body: { name: 'n.md', kind: 'text', text: 'hello', mime: 'text/markdown' } }); const fileId = r.id; ok(r.ok, 'create text file');
|
|
r = await req(`/files/${fileId}/raw`, { raw: true }); ok((await r.text()) === 'hello', 'download text file');
|
|
r = await req('/boards', { method: 'POST', body: { name: 'B', data: '{}', links: [{ fileId }] } }); ok(r.ok, 'create board');
|
|
cookies = {}; r = await req('/docs'); ok(r.status === 401, 'docs need auth');
|
|
|
|
console.log('== agent platform: classes ==');
|
|
cookies = adminCookies;
|
|
r = await req('/agents/classes', { method: 'POST', body: { name: 'Backend Coder', tags: ['backend', 'model'], description: 'api', visibility: 'public' } });
|
|
const classId = r.id; ok(r.ok && r.slug === 'backend-coder', 'publish class + slug');
|
|
await req('/agents/classes', { method: 'POST', body: { name: 'Frontend Pixie', tags: ['frontend'] } });
|
|
cookies = memberCookies; await req('/agents/classes', { method: 'POST', body: { name: 'Secret Bot', tags: ['x'], visibility: 'private' } });
|
|
cookies = {}; r = await req('/agents/classes'); ok(r.classes.length === 2, 'browse: only public classes anon');
|
|
r = await req('/agents/classes?tag=backend'); eq(r.classes.map((c) => c.name), ['Backend Coder'], 'filter by tag');
|
|
cookies = memberCookies; r = await req('/agents/classes?mine=1'); ok(r.classes.length === 1 && r.classes[0].name === 'Secret Bot', 'mine shows own private');
|
|
cookies = adminCookies; r = await req('/agents/classes'); ok(r.classes.length === 3, 'admin sees private too');
|
|
r = await req(`/agents/classes/${classId}/subscribe`, { method: 'POST' }); ok(r.token?.startsWith('sub_'), 'subscription token');
|
|
|
|
console.log('== agent platform: instances + lifecycle ==');
|
|
cookies = adminCookies;
|
|
r = await req('/agents/instances', { method: 'POST', body: { classId, visibility: 'public' } });
|
|
const instId = r.id, accesskey = r.accesskey; ok(r.ok && accesskey?.startsWith('ik_'), 'create instance + accesskey');
|
|
ok(r.relayWs.includes(accesskey), 'instance returns relay WS');
|
|
r = await req(`/agents/instances/${instId}`); eq(r.instance.status, 'pending', 'instance starts pending');
|
|
r = await req(`/agents/instances/${instId}/heartbeat`, { method: 'POST', cookie: false, body: { accesskey, broker: 'kebab', tui: 'booting' } }); eq(r.status, 'online', 'heartbeat -> online');
|
|
r = await req(`/agents/instances/${instId}`); eq(r.instance.status, 'online', 'instance online');
|
|
r = await req(`/agents/instances/${instId}/heartbeat`, { method: 'POST', cookie: false, body: { accesskey: 'WRONG' } }); ok(r.status === 401, 'bad accesskey rejected');
|
|
// stats reflect it
|
|
r = await req('/agents/stats'); ok(r.classes === 2 && r.instances === 1 && r.online === 1, 'stats classes/instances/online');
|
|
// session
|
|
r = await req(`/agents/instances/${instId}/sessions`, { method: 'POST', body: { connector: 'chatbox' } });
|
|
ok(r.ok && r.relayToken === accesskey && r.chatboxJs.includes(accesskey), 'start session -> relay token + chatbox js');
|
|
r = await req('/agents/stats'); ok(r.sessions === 1, 'stats sessions');
|
|
// private instance needs accesskey for a non-owner
|
|
cookies = adminCookies; r = await req('/agents/instances', { method: 'POST', body: { classId, visibility: 'private' } });
|
|
const privId = r.id, privKey = r.accesskey;
|
|
cookies = memberCookies; r = await req(`/agents/instances/${privId}`); ok(r.status === 403, 'private instance hidden from non-owner');
|
|
r = await req(`/agents/instances/${privId}?accesskey=${privKey}`); ok(r.ok, 'private instance visible with accesskey');
|
|
// shutdown
|
|
cookies = adminCookies; r = await req(`/agents/instances/${instId}`, { method: 'DELETE', body: { accesskey } }); ok(r.ok, 'shutdown instance');
|
|
r = await req(`/agents/instances/${instId}`); eq(r.instance.status, 'down', 'instance down after shutdown');
|
|
|
|
console.log('== webagent broker login ==');
|
|
cookies = {};
|
|
r = await req('/webagent/login', { method: 'POST', body: { username: 'admin@x.io', password: 'secret123' } }); ok(r.ok && r.token?.startsWith('wa_'), 'webagent login (amerc user)');
|
|
r = await req('/webagent/login', { method: 'POST', body: { username: 'admin@x.io', password: 'nope' } }); ok(r.status === 401, 'webagent login bad pw');
|
|
|
|
console.log(`\n${fail ? '❌' : '✅'} ${pass} passed, ${fail} failed`);
|
|
if (fail) console.log('FAILED: ' + fails.join(' | '));
|
|
srv.kill(); rmSync(dir, { recursive: true, force: true });
|
|
process.exit(fail ? 1 : 0);
|
|
}
|
|
main().catch((e) => { console.error(e); srv.kill(); process.exit(1); });
|