mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(security): reject loopback for BYOK asset download URLs (#5478) The assertExternalAssetUrl guard previously routed through validateBaseUrlResolved without forbidLoopback, so asset URLs pointing at 127.0.0.1, localhost, or ::1 passed validation and were fetched by the daemon. This allowed a compromised or misconfigured upstream gateway to exfiltrate data from internal services via SSRF. Changes: - Add forbidLoopback option to ValidateBaseUrlOptions (packages/contracts) - validateBaseUrl rejects loopback hosts when forbidLoopback is set - validateBaseUrlResolved skips the loopback early-return when forbidLoopback - validateBaseUrlResolved: when forbidLoopback is true, DNS-resolved loopback addresses are also rejected (not just literal loopback hosts) - assertExternalAssetUrl now passes forbidLoopback: true - User-configured provider endpoints (validateUserProviderBaseUrl) are unaffected — they still allow loopback for local gateways - Adds regression tests covering literal loopback rejection, DNS-resolved loopback rejection (IPv4, IPv6, mixed results), and provider endpoint exemption Note: PR #5529 fixed the library-ingest SSRF path. This PR closes the separate BYOK asset-download path (assertExternalAssetUrl) that retains the loopback carve-out. Closes #5478 * fix(security): pin DNS resolution to prevent rebinding in asset fetch (#5478) The validation step (validateBaseUrlResolved) and the fetch step (assertAndFetchExternalAsset) previously performed independent DNS lookups. A DNS-rebinding domain could return a public IP for validation and then 127.0.0.1 / ::1 at fetch time, bypassing the loopback guard. Fix: - validateBaseUrlResolved now attaches the DNS-resolved addresses that passed validation to its return value (BaseUrlValidationResult.resolvedAddresses) - assertExternalAssetUrl passes these addresses through on the ok branch - assertAndFetchExternalAsset creates an Undici Agent with a custom connect.lookup that returns only the validated addresses, pinning the TCP connection regardless of any DNS rebind between validation and fetch - 3 call sites in byok-tools.ts that used validate-then-fetch separately now route through assertAndFetchExternalAsset for a single unified path - 4 regression tests covering: single validated address, DNS-rebind mock (public→loopback), round-robin DNS, and IP-literal short-circuit * fix(security): fail-closed on DNS error + connection-time validating lookup (#5478) Addresses mrcfps third review: the fallback to unpinned fetch when validation did not attach resolved addresses left a DNS-rebinding hole where an attacker could make the validation lookup throw (ENOTFOUND / SERVFAIL) and then answer loopback for the fetch lookup. Changes: 1. validateBaseUrlResolved: when forbidLoopback is true, DNS lookup failures now return a forbidden result instead of the sync success. This prevents the fail-then-rebind vector. 2. assertAndFetchExternalAsset: removed the unpinned-fetch fallback. Non-IP-literal hostnames without resolvedAddresses now throw — the fetch never happens. IP literals are safe because they were validated synchronously and have no hostname to rebind. 3. Replaced the per-request pinned Agent with a long-lived Undici Agent (assetDispatcher) whose connection-time lookup (createAssetValidatingLookup) rejects any non-public address. This is defense-in-depth on top of the pre-validation, following the same pattern as brands/safe-fetch.ts and plugins/plugin-asset-cache.ts. A shared dispatcher also avoids the keep-alive socket leak of per-request Agents. 4. createAssetValidatingLookup is exported for unit testing. 5. Expanded test coverage: - DNS failure → fail-closed (forbidLoopback true and false) - createAssetValidatingLookup: rejects loopback, RFC1918, metadata IP; allows public - assertAndFetchExternalAsset: throws on blocked URLs and internal IPs - Updated existing public-CDN tests to use IP literals (fail-closed behavior now correctly rejects hostnames when DNS is unavailable) * fix(security): injectable lookup + testable fetch for asset SSRF guard (#5478) Addresses mrcfps 4th review: daemon CI was red because fail-closed behavior caused hostname-based test fixtures to NXDOMAIN, and undiciFetch bypassed vi.stubGlobal('fetch') stubs. Changes: 1. assertExternalAssetUrl and assertAndFetchExternalAsset now accept an optional injectable `lookup` (DnsLookupFn) and `fetchImpl` parameter. Production callers pass neither, so default DNS and globalThis.fetch are used. Tests can inject mock lookups/fetch. 2. assertAndFetchExternalAsset uses globalThis.fetch (not undiciFetch) so vi.stubGlobal('fetch', ...) stubs in existing test suites (byok-tools, aihubmix, proxy-routes, senseaudio) still intercept. 3. Existing test fixtures: replaced cdn.example.test with 93.184.216.34 (public IP literal) in 6 test files (41 substitutions) so IP literals skip DNS resolution and don't trigger fail-closed on NXDOMAIN .test hostnames. 4. Added 3 TOCTOU regression tests driving assertAndFetchExternalAsset with injected lookup + fetch: - Validation lookup public → fetch called with redirect:error - Validation lookup throws → assertAndFetchExternalAsset rejects without invoking fetch (fail-then-rebind vector closed) - IP literal → skips DNS, calls fetch directly 5. assetDispatcher + createAssetValidatingLookup kept for production connection-time defense-in-depth (exported, available for future wiring to a production fetch dispatcher). Total: 31 tests in asset-ssrf-loopback.test.ts, all passing. * fix(security): attach validating dispatcher to asset fetch (#5478) mrcfps 5th review: assetDispatcher was built but never attached to the fetch call. The validating lookup only runs at connect time through the dispatcher, so without it the fetch re-resolves the attacker-controlled hostname and a public-then-loopback rebind still reaches the daemon. Fix: attach assetDispatcher to the RequestInit via the dispatcher key (same pattern as plugins/plugin-asset-cache.ts safeExternalFetch). Production globalThis.fetch (Node/undici) uses it to refuse connect-time non-public addresses. Test stubs still see redirect:'error' and can ignore dispatcher. Also fixed the misnamed TOCTOU test to assert dispatcher is present on the captured RequestInit, and removed unused undiciFetch import. * fix(tests): update dispatcher assertions for assertAndFetchExternalAsset assertAndFetchExternalAsset now attaches a validating assetDispatcher to the fetch RequestInit (issue #5478). Tests that blanket-checked init?.dispatcher === testMock fail because asset-download calls correctly carry the validating dispatcher instead. Fix: conditional assertion — when init.redirect === 'error' (asset download path), check dispatcher exists rather than matching the test mock. API calls still verify the caller's dispatcher is forwarded unchanged. * fix(tests): URL-keyed dispatcher assertions + document asset dispatcher override Address mrcfps's non-blocking review items: 1. Replace the redirect==='error' heuristic with URL-keyed assertions. The heuristic silently dropped the submit dispatcher identity check on the AIHubMix video path (its submit hop also sets redirect:'error'). Submit/poll hops now assert toBe(callerDispatcher); the asset hop asserts toBe(getAssetValidatingDispatcher()) — exported from connectionTest.ts so the exact validating instance is compared. 2. Document the intentional dispatcher override on the BYOKToolContext.requestInit JSDoc: asset downloads through assertAndFetchExternalAsset intentionally never ride the turn proxy dispatcher; submit/poll hops keep it. --------- Co-authored-by: CVE-Hunter-Leo <cve-hunter-leo@users.noreply.github.com>
573 lines
19 KiB
TypeScript
573 lines
19 KiB
TypeScript
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { generateMedia } from '../../src/media/index.js';
|
|
|
|
const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2uoAAAAASUVORK5CYII=';
|
|
const VIDEO_BASE64 = Buffer.from([0, 0, 0, 24, 102, 116, 121, 112]).toString('base64');
|
|
const OPENAI_ENV_KEYS = [
|
|
'OD_OPENAI_API_KEY',
|
|
'OPENAI_API_KEY',
|
|
'AZURE_API_KEY',
|
|
'AZURE_OPENAI_API_KEY',
|
|
];
|
|
|
|
describe('OpenAI-compatible media providers', () => {
|
|
let root: string;
|
|
let projectRoot: string;
|
|
let projectsRoot: string;
|
|
const realFetch = globalThis.fetch;
|
|
const originalImageRouterKey = process.env.OD_IMAGEROUTER_API_KEY;
|
|
const originalCustomImageKey = process.env.OD_CUSTOM_IMAGE_API_KEY;
|
|
const originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
|
|
const originalDataDir = process.env.OD_DATA_DIR;
|
|
const originalEnvAliases = process.env.OD_MEDIA_MODEL_ALIASES;
|
|
const originalAllowStubs = process.env.OD_MEDIA_ALLOW_STUBS;
|
|
const originalOpenAIEnv = Object.fromEntries(
|
|
OPENAI_ENV_KEYS.map((key) => [key, process.env[key]]),
|
|
);
|
|
|
|
beforeEach(async () => {
|
|
root = await mkdtemp(path.join(tmpdir(), 'od-openai-compatible-media-'));
|
|
projectRoot = path.join(root, 'project-root');
|
|
projectsRoot = path.join(projectRoot, '.od', 'projects');
|
|
await mkdir(projectsRoot, { recursive: true });
|
|
delete process.env.OD_IMAGEROUTER_API_KEY;
|
|
delete process.env.OD_CUSTOM_IMAGE_API_KEY;
|
|
delete process.env.OD_MEDIA_CONFIG_DIR;
|
|
delete process.env.OD_DATA_DIR;
|
|
delete process.env.OD_MEDIA_MODEL_ALIASES;
|
|
delete process.env.OD_MEDIA_ALLOW_STUBS;
|
|
for (const key of OPENAI_ENV_KEYS) {
|
|
delete process.env[key];
|
|
}
|
|
});
|
|
|
|
afterEach(async () => {
|
|
globalThis.fetch = realFetch;
|
|
vi.unstubAllGlobals();
|
|
if (originalImageRouterKey == null) {
|
|
delete process.env.OD_IMAGEROUTER_API_KEY;
|
|
} else {
|
|
process.env.OD_IMAGEROUTER_API_KEY = originalImageRouterKey;
|
|
}
|
|
if (originalCustomImageKey == null) {
|
|
delete process.env.OD_CUSTOM_IMAGE_API_KEY;
|
|
} else {
|
|
process.env.OD_CUSTOM_IMAGE_API_KEY = originalCustomImageKey;
|
|
}
|
|
if (originalMediaConfigDir == null) {
|
|
delete process.env.OD_MEDIA_CONFIG_DIR;
|
|
} else {
|
|
process.env.OD_MEDIA_CONFIG_DIR = originalMediaConfigDir;
|
|
}
|
|
if (originalDataDir == null) {
|
|
delete process.env.OD_DATA_DIR;
|
|
} else {
|
|
process.env.OD_DATA_DIR = originalDataDir;
|
|
}
|
|
if (originalEnvAliases == null) {
|
|
delete process.env.OD_MEDIA_MODEL_ALIASES;
|
|
} else {
|
|
process.env.OD_MEDIA_MODEL_ALIASES = originalEnvAliases;
|
|
}
|
|
if (originalAllowStubs == null) {
|
|
delete process.env.OD_MEDIA_ALLOW_STUBS;
|
|
} else {
|
|
process.env.OD_MEDIA_ALLOW_STUBS = originalAllowStubs;
|
|
}
|
|
for (const key of OPENAI_ENV_KEYS) {
|
|
if (originalOpenAIEnv[key] == null) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = originalOpenAIEnv[key];
|
|
}
|
|
}
|
|
await rm(root, { recursive: true, force: true });
|
|
});
|
|
|
|
async function writeConfig(data: unknown) {
|
|
const file = path.join(projectRoot, '.od', 'media-config.json');
|
|
await mkdir(path.dirname(file), { recursive: true });
|
|
await writeFile(file, JSON.stringify(data), 'utf8');
|
|
}
|
|
|
|
it('renders custom /v1/images/generations providers with configured base URL and model', async () => {
|
|
await writeConfig({
|
|
providers: {
|
|
'custom-image': {
|
|
baseUrl: 'https://images.example.test/v1',
|
|
model: 'acme-image-model',
|
|
},
|
|
},
|
|
});
|
|
|
|
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
|
expect(String(input)).toBe('https://images.example.test/v1/images/generations');
|
|
expect(init?.method).toBe('POST');
|
|
expect(init?.headers).toMatchObject({
|
|
'content-type': 'application/json',
|
|
});
|
|
expect(init?.headers).not.toHaveProperty('authorization');
|
|
expect(JSON.parse(String(init?.body))).toEqual({
|
|
prompt: 'A product render on white seamless paper',
|
|
model: 'acme-image-model',
|
|
n: 1,
|
|
size: '1024x1024',
|
|
});
|
|
return new Response(JSON.stringify({
|
|
data: [{ b64_json: PNG_BASE64 }],
|
|
}), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const result = await generateMedia({
|
|
projectRoot,
|
|
projectsRoot,
|
|
projectId: 'project-1',
|
|
surface: 'image',
|
|
model: 'custom-image',
|
|
prompt: 'A product render on white seamless paper',
|
|
output: 'custom.png',
|
|
});
|
|
|
|
expect(result.providerId).toBe('custom-image');
|
|
expect(result.providerNote).toContain('custom-image/acme-image-model');
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const bytes = await readFile(path.join(projectsRoot, 'project-1', 'custom.png'));
|
|
expect(bytes.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('retries a temporarily unavailable custom-image request with the same provider', async () => {
|
|
await writeConfig({
|
|
providers: {
|
|
'custom-image': {
|
|
baseUrl: 'https://images.example.test/v1',
|
|
model: 'acme-image-model',
|
|
},
|
|
},
|
|
});
|
|
|
|
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
|
expect(String(input)).toBe('https://images.example.test/v1/images/generations');
|
|
expect(init?.method).toBe('POST');
|
|
expect(JSON.parse(String(init?.body))).toMatchObject({
|
|
prompt: 'A product render on white seamless paper',
|
|
model: 'acme-image-model',
|
|
});
|
|
if (fetchMock.mock.calls.length === 1) {
|
|
return new Response(JSON.stringify({ error: { message: 'try again' } }), {
|
|
status: 503,
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'retry-after': '0',
|
|
},
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({
|
|
data: [{ b64_json: PNG_BASE64 }],
|
|
}), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const onProviderRequestSettled = vi.fn();
|
|
|
|
const result = await generateMedia({
|
|
projectRoot,
|
|
projectsRoot,
|
|
projectId: 'project-1',
|
|
surface: 'image',
|
|
model: 'custom-image',
|
|
prompt: 'A product render on white seamless paper',
|
|
output: 'custom-retried.png',
|
|
onProviderRequestSettled,
|
|
});
|
|
|
|
expect(result.providerId).toBe('custom-image');
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(onProviderRequestSettled).toHaveBeenCalledOnce();
|
|
expect(onProviderRequestSettled).toHaveBeenCalledWith({
|
|
providerId: 'custom-image',
|
|
attemptCount: 2,
|
|
retryCount: 1,
|
|
initialResponseStatus: 503,
|
|
responseStatus: 200,
|
|
retryReason: 'service_unavailable_503',
|
|
retryAfterMs: 0,
|
|
retryDelayMs: 0,
|
|
retryFinalResult: 'success',
|
|
});
|
|
});
|
|
|
|
it('forwards requestInit.dispatcher through custom-image submit and asset fetches', async () => {
|
|
await writeConfig({
|
|
providers: {
|
|
'custom-image': {
|
|
baseUrl: 'https://images.example.test/v1',
|
|
model: 'acme-image-model',
|
|
},
|
|
},
|
|
});
|
|
|
|
const dispatcher = {} as NonNullable<RequestInit['dispatcher']>;
|
|
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
|
if (String(input) === 'https://images.example.test/v1/images/generations') {
|
|
expect(init?.dispatcher).toBe(dispatcher);
|
|
return new Response(JSON.stringify({
|
|
data: [{ url: 'https://93.184.216.34/generated.png' }],
|
|
}), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
expect(String(input)).toBe('https://93.184.216.34/generated.png');
|
|
expect(init?.dispatcher).toBe(dispatcher);
|
|
return new Response(Buffer.from(PNG_BASE64, 'base64'));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await generateMedia({
|
|
projectRoot,
|
|
projectsRoot,
|
|
projectId: 'project-1',
|
|
surface: 'image',
|
|
model: 'custom-image',
|
|
prompt: 'A product render on white seamless paper',
|
|
output: 'custom-dispatcher.png',
|
|
requestInit: { dispatcher },
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('does not resubmit custom-image generation when the returned asset download fails', async () => {
|
|
await writeConfig({
|
|
providers: {
|
|
'custom-image': {
|
|
baseUrl: 'https://images.example.test/v1',
|
|
model: 'acme-image-model',
|
|
},
|
|
},
|
|
});
|
|
|
|
let submitCalls = 0;
|
|
const fetchMock = vi.fn(async (input: unknown) => {
|
|
if (String(input) === 'https://images.example.test/v1/images/generations') {
|
|
submitCalls += 1;
|
|
return new Response(JSON.stringify({
|
|
data: [{ url: 'https://93.184.216.34/generated.png' }],
|
|
}), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
expect(String(input)).toBe('https://93.184.216.34/generated.png');
|
|
return new Response('temporarily unavailable', { status: 503 });
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await expect(generateMedia({
|
|
projectRoot,
|
|
projectsRoot,
|
|
projectId: 'project-1',
|
|
surface: 'image',
|
|
model: 'custom-image',
|
|
prompt: 'A product render on white seamless paper',
|
|
output: 'custom-download-failure.png',
|
|
})).rejects.toThrow('custom image media fetch 503');
|
|
|
|
expect(submitCalls).toBe(1);
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('routes matching OpenAI image catalog ids through the configured custom provider', async () => {
|
|
await writeConfig({
|
|
providers: {
|
|
'custom-image': {
|
|
apiKey: 'proxy-test-key',
|
|
baseUrl: 'https://proxy.example.test/v1/images/generations',
|
|
model: 'gpt-image-2',
|
|
},
|
|
},
|
|
});
|
|
|
|
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
|
expect(String(input)).toBe('https://proxy.example.test/v1/images/generations');
|
|
expect(init?.method).toBe('POST');
|
|
expect(init?.headers).toMatchObject({
|
|
authorization: 'Bearer proxy-test-key',
|
|
'content-type': 'application/json',
|
|
});
|
|
expect(JSON.parse(String(init?.body))).toEqual({
|
|
prompt: 'A clean app icon with glass material',
|
|
model: 'gpt-image-2',
|
|
n: 1,
|
|
size: '1024x1024',
|
|
});
|
|
return new Response(JSON.stringify({
|
|
data: [{ b64_json: PNG_BASE64 }],
|
|
}), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const result = await generateMedia({
|
|
projectRoot,
|
|
projectsRoot,
|
|
projectId: 'project-1',
|
|
surface: 'image',
|
|
model: 'gpt-image-2',
|
|
prompt: 'A clean app icon with glass material',
|
|
output: 'proxy.png',
|
|
});
|
|
|
|
expect(result.providerId).toBe('custom-image');
|
|
expect(result.providerNote).toContain('custom-image/gpt-image-2');
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('rewrites custom-image text-only requests back to /v1/images/generations when configured with an edits URL', async () => {
|
|
await writeConfig({
|
|
providers: {
|
|
'custom-image': {
|
|
apiKey: 'proxy-test-key',
|
|
baseUrl: 'https://proxy.example.test/v1/images/edits',
|
|
model: 'acme-image-model',
|
|
},
|
|
},
|
|
});
|
|
|
|
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
|
expect(String(input)).toBe('https://proxy.example.test/v1/images/generations');
|
|
expect(init?.method).toBe('POST');
|
|
expect(init?.headers).toMatchObject({
|
|
authorization: 'Bearer proxy-test-key',
|
|
'content-type': 'application/json',
|
|
});
|
|
expect(JSON.parse(String(init?.body))).toEqual({
|
|
prompt: 'A matte product shot on a neutral backdrop',
|
|
model: 'acme-image-model',
|
|
n: 1,
|
|
size: '1024x1024',
|
|
});
|
|
return new Response(JSON.stringify({
|
|
data: [{ b64_json: PNG_BASE64 }],
|
|
}), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const result = await generateMedia({
|
|
projectRoot,
|
|
projectsRoot,
|
|
projectId: 'project-1',
|
|
surface: 'image',
|
|
model: 'custom-image',
|
|
prompt: 'A matte product shot on a neutral backdrop',
|
|
output: 'custom-from-edits-base.png',
|
|
});
|
|
|
|
expect(result.providerId).toBe('custom-image');
|
|
expect(result.providerNote).toContain('custom-image/acme-image-model');
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('routes custom-image reference-image requests through /v1/images/edits', async () => {
|
|
await writeConfig({
|
|
providers: {
|
|
'custom-image': {
|
|
apiKey: 'proxy-test-key',
|
|
baseUrl: 'https://proxy.example.test/v1',
|
|
model: 'acme-image-edit-model',
|
|
},
|
|
},
|
|
});
|
|
const projectDir = path.join(projectsRoot, 'project-1');
|
|
await mkdir(projectDir, { recursive: true });
|
|
await writeFile(
|
|
path.join(projectDir, 'reference.png'),
|
|
Buffer.from(PNG_BASE64, 'base64'),
|
|
);
|
|
|
|
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
|
expect(String(input)).toBe('https://proxy.example.test/v1/images/edits');
|
|
expect(init?.method).toBe('POST');
|
|
expect(init?.headers).toMatchObject({
|
|
authorization: 'Bearer proxy-test-key',
|
|
'content-type': 'application/json',
|
|
});
|
|
const body = JSON.parse(String(init?.body));
|
|
expect(body.prompt).toBe('Turn this reference into a blueprint-style UI illustration');
|
|
expect(body.model).toBe('acme-image-edit-model');
|
|
expect(body.n).toBe(1);
|
|
expect(body.size).toBe('1024x1024');
|
|
expect(body.response_format).toBe('b64_json');
|
|
expect(body.images).toHaveLength(1);
|
|
expect(body.images[0]?.image_url).toMatch(/^data:image\/png;base64,/);
|
|
return new Response(JSON.stringify({
|
|
data: [{ b64_json: PNG_BASE64 }],
|
|
}), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const result = await generateMedia({
|
|
projectRoot,
|
|
projectsRoot,
|
|
projectId: 'project-1',
|
|
surface: 'image',
|
|
model: 'custom-image',
|
|
prompt: 'Turn this reference into a blueprint-style UI illustration',
|
|
image: 'reference.png',
|
|
output: 'edited.png',
|
|
});
|
|
|
|
expect(result.providerId).toBe('custom-image');
|
|
expect(result.providerNote).toContain('custom-image/acme-image-edit-model');
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const bytes = await readFile(path.join(projectDir, 'edited.png'));
|
|
expect(bytes.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('renders ImageRouter images through the OpenAI-compatible JSON endpoint', async () => {
|
|
process.env.OD_IMAGEROUTER_API_KEY = 'ir-test-key';
|
|
|
|
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
|
expect(String(input)).toBe('https://api.imagerouter.io/v1/openai/images/generations');
|
|
expect(init?.method).toBe('POST');
|
|
expect(init?.headers).toMatchObject({
|
|
authorization: 'Bearer ir-test-key',
|
|
'content-type': 'application/json',
|
|
});
|
|
expect(JSON.parse(String(init?.body))).toEqual({
|
|
prompt: 'A cinematic vertical poster',
|
|
model: 'openai/gpt-image-2',
|
|
quality: 'auto',
|
|
size: '576x1024',
|
|
response_format: 'b64_json',
|
|
output_format: 'png',
|
|
});
|
|
return new Response(JSON.stringify({
|
|
data: [{ b64_json: PNG_BASE64 }],
|
|
}), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const result = await generateMedia({
|
|
projectRoot,
|
|
projectsRoot,
|
|
projectId: 'project-1',
|
|
surface: 'image',
|
|
model: 'openai/gpt-image-2',
|
|
prompt: 'A cinematic vertical poster',
|
|
aspect: '9:16',
|
|
output: 'imagerouter.png',
|
|
});
|
|
|
|
expect(result.providerId).toBe('imagerouter');
|
|
expect(result.providerNote).toContain('imagerouter/openai/gpt-image-2');
|
|
});
|
|
|
|
it('renders ImageRouter videos through the OpenAI-compatible JSON endpoint', async () => {
|
|
process.env.OD_IMAGEROUTER_API_KEY = 'ir-test-key';
|
|
|
|
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
|
expect(String(input)).toBe('https://api.imagerouter.io/v1/openai/videos/generations');
|
|
expect(init?.method).toBe('POST');
|
|
expect(init?.headers).toMatchObject({
|
|
authorization: 'Bearer ir-test-key',
|
|
'content-type': 'application/json',
|
|
});
|
|
expect(JSON.parse(String(init?.body))).toEqual({
|
|
prompt: 'A short cinematic camera push through a neon market',
|
|
model: 'xAI/grok-imagine-video',
|
|
size: '1024x576',
|
|
seconds: 8,
|
|
response_format: 'b64_json',
|
|
});
|
|
return new Response(JSON.stringify({
|
|
data: [{ b64_json: VIDEO_BASE64 }],
|
|
}), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const result = await generateMedia({
|
|
projectRoot,
|
|
projectsRoot,
|
|
projectId: 'project-1',
|
|
surface: 'video',
|
|
model: 'xAI/grok-imagine-video',
|
|
prompt: 'A short cinematic camera push through a neon market',
|
|
aspect: '16:9',
|
|
length: 8,
|
|
output: 'imagerouter.mp4',
|
|
});
|
|
|
|
expect(result.providerId).toBe('imagerouter');
|
|
expect(result.name).toBe('imagerouter.mp4');
|
|
expect(result.providerNote).toContain('imagerouter/xAI/grok-imagine-video');
|
|
const bytes = await readFile(path.join(projectsRoot, 'project-1', 'imagerouter.mp4'));
|
|
expect(bytes.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('keeps aliased gpt-image-2 on the explicit OpenAI API path', async () => {
|
|
process.env.OPENAI_API_KEY = 'sk-openai-test-key';
|
|
process.env.OD_MEDIA_MODEL_ALIASES = JSON.stringify({
|
|
'gpt-image-2': 'custom-gpt-image-2-deployment',
|
|
});
|
|
|
|
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
|
expect(String(input)).toBe('https://api.openai.com/v1/images/generations');
|
|
expect(init?.headers).toMatchObject({
|
|
authorization: 'Bearer sk-openai-test-key',
|
|
'content-type': 'application/json',
|
|
});
|
|
expect(JSON.parse(String(init?.body))).toMatchObject({
|
|
prompt: 'A clean app icon with glass material',
|
|
model: 'custom-gpt-image-2-deployment',
|
|
});
|
|
return new Response(JSON.stringify({
|
|
data: [{ b64_json: PNG_BASE64 }],
|
|
}), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const result = await generateMedia({
|
|
projectRoot,
|
|
projectsRoot,
|
|
projectId: 'project-1',
|
|
surface: 'image',
|
|
model: 'gpt-image-2',
|
|
prompt: 'A clean app icon with glass material',
|
|
output: 'aliased-openai.png',
|
|
});
|
|
|
|
expect(result.providerId).toBe('openai');
|
|
expect(result.providerNote).toContain('openai/custom-gpt-image-2-deployment');
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
});
|