Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
119 lines
5.5 KiB
JavaScript
119 lines
5.5 KiB
JavaScript
// @vitest-environment node
|
||
/**
|
||
* Regression guard for ADR-0016: agent-generated images/3D models MUST be
|
||
* registered in the `files` table, otherwise uploadsFileGuard 404s every
|
||
* generated asset even though the bytes are on disk.
|
||
*
|
||
* Bug history: downloadAndSaveImage / downloadAndSave3DModel wrote to disk
|
||
* and returned a URL but never inserted a `files` row → every avatar/image an
|
||
* agent produced was unreachable. This test pins the INSERT (with public
|
||
* visibility) so the regression cannot return silently.
|
||
*/
|
||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
|
||
const dbRun = vi.fn().mockResolvedValue({});
|
||
|
||
vi.mock('../../../database/connection.js', () => ({
|
||
dbGet: vi.fn().mockResolvedValue(null),
|
||
dbRun,
|
||
sqlNow: () => 'NOW()',
|
||
}));
|
||
|
||
// Avoid touching the real filesystem.
|
||
vi.mock('fs', () => {
|
||
const fakeFs = {
|
||
existsSync: vi.fn().mockReturnValue(true),
|
||
mkdirSync: vi.fn(),
|
||
writeFileSync: vi.fn(),
|
||
statSync: vi.fn().mockReturnValue({ size: 1234 }),
|
||
};
|
||
return { default: fakeFs, ...fakeFs };
|
||
});
|
||
|
||
const { downloadAndSaveImage, downloadAndSave3DModel, REPLICATE_MODELS } = await import('../image-tools.js');
|
||
|
||
describe('agent image registration (ADR-0016)', () => {
|
||
beforeEach(() => dbRun.mockClear());
|
||
|
||
it('inserts a public files row when saving a generated image', async () => {
|
||
// data: URI keeps it offline — no network fetch.
|
||
const pngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
|
||
const result = await downloadAndSaveImage(`data:image/png;base64,${pngBase64}`, 44);
|
||
|
||
expect(dbRun).toHaveBeenCalledTimes(1);
|
||
const [sql, params] = dbRun.mock.calls[0];
|
||
expect(sql).toMatch(/INSERT INTO files/i);
|
||
// Column order: id, name, original_name, mime_type, size, path, url,
|
||
// <'local'>, space_id, uploaded_by, <'public'>, ...
|
||
expect(params[0]).toBe(result.file_id); // id
|
||
expect(params[3]).toBe('image/png'); // mime_type
|
||
expect(params[6]).toBe(result.url); // url == relative /uploads/...
|
||
expect(params[6]).toMatch(/^\/uploads\/spaces\/44\//);
|
||
// 'local' and 'public' are SQL literals, not bound params, so space_id is
|
||
// params[7] and uploaded_by is params[8].
|
||
expect(params[7]).toBe(44); // space_id (numeric, not 'plugin')
|
||
expect(params[8]).toBeNull(); // uploaded_by (agent upload)
|
||
expect(sql).toMatch(/'public'/);
|
||
});
|
||
|
||
it("maps the 'plugin' space sentinel to a null space_id", async () => {
|
||
const pngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
|
||
await downloadAndSaveImage(`data:image/png;base64,${pngBase64}`, 'plugin');
|
||
const [, params] = dbRun.mock.calls[0];
|
||
expect(params[7]).toBeNull(); // space_id sentinel → null
|
||
});
|
||
|
||
it('registers generated 3D models too', async () => {
|
||
// 3D path fetches over HTTP — stub fetch to return bytes.
|
||
global.fetch = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||
});
|
||
const result = await downloadAndSave3DModel('https://example.com/mesh.glb', 44, 'glb');
|
||
expect(dbRun).toHaveBeenCalledTimes(1);
|
||
const [sql, params] = dbRun.mock.calls[0];
|
||
expect(sql).toMatch(/INSERT INTO files/i);
|
||
expect(params[3]).toBe('model/gltf-binary'); // mime_type
|
||
expect(params[6]).toBe(result.url);
|
||
expect(sql).toMatch(/'public'/);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Regression guard: pure text-to-image (no source image) must NOT seed a null
|
||
* image field. Bug history: every edit-capable model unconditionally built
|
||
* `image: imageUrl` / `[imageUrl]` (+ `aspect_ratio: 'match_input_image'`), so
|
||
* with no source image Replicate received `null` / `[null]` and returned 422 —
|
||
* blocking avatar generation across flux-2-pro, seedream-*, ideogram, etc.
|
||
*/
|
||
describe('replicate buildInput — text-to-image omits null image (422 guard)', () => {
|
||
const PROMPT = 'a red stamp that reads НЕ СОГЛАСОВАНО';
|
||
|
||
it('never emits a null/[null] image field when imageUrl is absent', () => {
|
||
for (const [key, model] of Object.entries(REPLICATE_MODELS)) {
|
||
const input = model.buildInput(PROMPT, null, null, 1);
|
||
// No image-bearing key may carry a null or [null] value. Omitting the
|
||
// key entirely (undefined) is the desired outcome — only an explicit
|
||
// null / [null] is the 422-triggering bug. (toContain throws on
|
||
// undefined, so guard the array checks behind a presence test.)
|
||
expect(input.image, `${key}.image`).not.toBeNull();
|
||
expect(input.input_image, `${key}.input_image`).not.toBeNull();
|
||
for (const arrKey of ['image_input', 'input_images']) {
|
||
if (input[arrKey] !== undefined) {
|
||
expect(input[arrKey], `${key}.${arrKey}`).not.toContain(null);
|
||
}
|
||
}
|
||
// Aspect ratio can't reference a non-existent input image.
|
||
expect(input.aspect_ratio, `${key}.aspect_ratio`).not.toBe('match_input_image');
|
||
expect(input.prompt).toBe(PROMPT);
|
||
}
|
||
});
|
||
|
||
it('still wires the source image through when one IS provided', () => {
|
||
const src = 'https://crm.hltrn.cc/uploads/spaces/44/src.png';
|
||
expect(REPLICATE_MODELS['flux-2-pro'].buildInput(PROMPT, src).input_images).toContain(src);
|
||
expect(REPLICATE_MODELS['seedream-4.5'].buildInput(PROMPT, src).image_input).toContain(src);
|
||
expect(REPLICATE_MODELS['ideogram-v3-balanced'].buildInput(PROMPT, src).image).toBe(src);
|
||
expect(REPLICATE_MODELS['flux-kontext-pro'].buildInput(PROMPT, src).input_image).toBe(src);
|
||
});
|
||
});
|