diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 2a589f5e53..84a5db2215 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -196,8 +196,8 @@ "_rebaseline_2026_07_23_8252_combo_400_advance": "#8252 (@RaviTharuma) own growth: accountFallback.ts 1932->1940 (+8) + combo.ts 3604->3630 (+26) — advance combo on model-scoped 400s wrapped as invalid/Bad-Request. Irreducible wiring at existing account-fallback + combo dispatch chokepoints. Covered by combo-model-scoped-400-advance.test.ts.", "_rebaseline_2026_07_23_8247_8248_model_unhealthy": "#8247+#8248 own growth: accountFallback.ts 1940->1941 (+1, irreducible import statement only — the substantive #8248 DEGRADED-pattern classifier was extracted into open-sse/config/errorConfig.ts, which has ample headroom, instead of growing this frozen file; #8247's fix is a single existing-line condition change, net zero lines). Scoping the credits-exhausted 403/429 branch to isCompatibleProvider() (per-model-quota openai/anthropic-compatible-* nicknames) so it stays model-scoped instead of terminalling the whole connection, and classifying NVIDIA NIM 'Function ... DEGRADED' 400 bodies as model-access-denied instead of a raw passthrough 400. Covered by tests/unit/8247-accountfallback-model-unhealthy.test.ts and tests/unit/8248-accountfallback-nvidia-degraded.test.ts.", "open-sse/services/accountFallback.ts": 1966, - "open-sse/services/adobeFireflyClient.ts": 2316, - "_rebaseline_2026_07_25_adobe_firefly_reference_images": "Follow-up to #8006: storage upload + referenceBlobs for image/video and /v1/images/edits dispatch. adobeFireflyClient.ts 1958->2316 (+upload helpers, extract sources, resolve blob ids).", + "open-sse/services/adobeFireflyClient.ts": 2317, + "_rebaseline_2026_07_25_adobe_firefly_reference_images": "Follow-up to #8006: storage upload + referenceBlobs for image/video and /v1/images/edits dispatch. adobeFireflyClient.ts 1958->2317 (+upload helpers, extract sources, resolve blob ids). Note: 2317 not 2316 — check-file-size.mjs counts LOC via split(\"\\n\").length (counts the trailing-newline empty element), which is 1 higher than `wc -l` on a file ending in \\n; the PR's original entry (2316) was measured with wc -l and undercounted by 1 against the actual gate.", "open-sse/services/batchProcessor.ts": 915, "open-sse/services/browserBackedChat.ts": 850, "open-sse/services/claudeCodeCompatible.ts": 1202, @@ -342,6 +342,8 @@ }, "testCap": 800, "testFrozen": { + "_rebaseline_2026_07_25_8510_adobe_firefly_reference_images_tests": "#8510 (artickc, feat/adobe-firefly-reference-images) own test growth: tests/unit/adobe-firefly.test.ts 711->871 (+159, entirely this PR's diff — new referenceBlobs upload/dispatch coverage for handleAdobeFireflyImageGeneration, resolveAdobeSourceImageIds, and the storage-upload wire contract). Route-level /v1/images/edits coverage (credentials/rate-limit/4-ref-cap branches added to route.ts) lives in the new tests/unit/8510-adobe-firefly-edits-route.test.ts instead of growing this file further.", + "tests/unit/adobe-firefly.test.ts": 871, "_rebaseline_2026_07_09_6126_clinepass_dualauth": "#6126 (ClinePass dual-auth) own test growth: oauth-providers-config.test.ts 842->845 (+3: clinepass key/config/required-fields entries reusing the Cline WorkOS flow config, needed after registering clinepass in the oauth.ts PROVIDERS enum).", "_rebaseline_2026_06_27_5193_antigravity_test": "#5193 own test growth: oauth-providers-config.test.ts 870->873 (+3: antigravity projectId assertion + 50ms tick for the now fire-and-forget onboarding, matching the no-PKCE/no-openid flow).", "_rebaseline_2026_07_02_5928_base_red": "web-cookie-providers-new.test.ts 845->850: #5928 (test(security) Kimi Web URL host parse, CodeQL #689) grew the file +5 lines and merged into release/v3.8.44 WITHOUT rebaselining, leaving a fast-gates base-red that blocked every subsequent PR->release. Test growth is legitimate (a security regression test); maintainer absorbs the drift here. Frozen at 850.", diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index d2f2cc31f5..631f32ac45 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -74,14 +74,15 @@ import { handleSegmindImageGeneration } from "./imageGeneration/providers/segmin import { handleDesignerWebImageGeneration } from "./imageGeneration/providers/designerWeb.ts"; import { handleMinimaxImageGeneration } from "./imageGeneration/providers/minimax.ts"; import { handleAdobeFireflyImageGeneration } from "./imageGeneration/providers/adobeFirefly.ts"; -// Re-export so /v1/images/edits can dispatch Firefly reference-image edits. -export { handleAdobeFireflyImageGeneration }; import { handleAlibabaImageGeneration } from "./imageGeneration/providers/alibabaImage.ts"; import { applyPollinationsAnonymousFallback, reportPollinationsAnonOutcome, } from "./imageGeneration/pollinationsAnonAuth.ts"; +// Re-export so /v1/images/edits can dispatch Firefly reference-image edits. +export { handleAdobeFireflyImageGeneration }; + interface KieImageOptions { model: string; provider: string; diff --git a/open-sse/services/adobeFireflyClient.ts b/open-sse/services/adobeFireflyClient.ts index e9e4d12156..0d408d8479 100644 --- a/open-sse/services/adobeFireflyClient.ts +++ b/open-sse/services/adobeFireflyClient.ts @@ -1245,7 +1245,7 @@ export async function uploadAdobeFireflyImage(opts: { cookie: cookieHeader || undefined, prompt: opts.prompt || "upload", }), - body: buffer, + body: buffer as unknown as BodyInit, }); const text = await resp.text().catch(() => ""); diff --git a/tests/unit/8510-adobe-firefly-edits-route.test.ts b/tests/unit/8510-adobe-firefly-edits-route.test.ts new file mode 100644 index 0000000000..5e80cad4d4 --- /dev/null +++ b/tests/unit/8510-adobe-firefly-edits-route.test.ts @@ -0,0 +1,230 @@ +// #8510 (artickc, feat/adobe-firefly-reference-images): route-level coverage for the Adobe +// Firefly branch that /v1/images/edits gained in this PR. Exercises the actual +// POST(request) handler (not the inner handleAdobeFireflyImageGeneration helper directly, +// which tests/unit/adobe-firefly.test.ts already covers) so the credentials / +// rate-limit / 4-reference-cap branches added to route.ts itself are proven, not just the +// downstream service call. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-adobe-firefly-edits-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "adobe-firefly-edits-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const imageEditRoute = await import("../../src/app/api/v1/images/edits/route.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +const { + ADOBE_FIREFLY_IMAGE_UPLOAD_URL, + ADOBE_FIREFLY_IMAGE_SUBMIT_URL, +} = await import("../../open-sse/services/adobeFireflyClient.ts"); + +interface ErrorResponseBody { + error: { message: string; code?: string }; +} + +interface ImageResponseBody { + data: Array<{ b64_json?: string; url?: string }>; +} + +const originalFetch = globalThis.fetch; + +async function resetStorage() { + globalThis.fetch = originalFetch; + apiKeysDb.resetApiKeyState(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); +} + +async function seedAdobeFireflyConnection( + overrides: { apiKey?: string; rateLimitedUntil?: string | null } = {} +) { + return providersDb.createProviderConnection({ + provider: "adobe-firefly", + authType: "apikey", + name: "adobe-firefly-test", + apiKey: overrides.apiKey ?? userImsJwt(), + isActive: true, + testStatus: "active", + rateLimitedUntil: overrides.rateLimitedUntil ?? null, + }); +} + +// Mirrors tests/unit/adobe-firefly.test.ts's userImsJwt() helper — a synthetic, +// non-guest IMS access token shape so resolveAdobeAccessToken() accepts it directly +// without needing a live cookie->token exchange call. +function userImsJwt(userId = "0EB@AdobeID"): string { + return ( + `eyJhbGciOiJSUzI1NiJ9.` + + Buffer.from( + JSON.stringify({ user_id: userId, type: "access_token", client_id: "clio-playground-web" }) + ).toString("base64url") + + `.` + + "sig".padEnd(40, "x") + ); +} + +function dataUrlPng(bytes: number[]): string { + return `data:image/png;base64,${Buffer.from(bytes).toString("base64")}`; +} + +const REF_A = dataUrlPng([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1]); +const REF_B = dataUrlPng([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 2]); + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + globalThis.fetch = originalFetch; + apiKeysDb.resetApiKeyState(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#8510 v1 image edit POST uploads Adobe Firefly reference images and dispatches referenceBlobs", async () => { + await seedAdobeFireflyConnection(); + + const uploadedIds: string[] = []; + let submitBody: Record | null = null; + + globalThis.fetch = async (url, init: RequestInit = {}) => { + const stringUrl = String(url); + if (stringUrl === ADOBE_FIREFLY_IMAGE_UPLOAD_URL) { + const id = `blob-${uploadedIds.length + 1}`; + uploadedIds.push(id); + return new Response(JSON.stringify({ images: [{ id }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (stringUrl === ADOBE_FIREFLY_IMAGE_SUBMIT_URL) { + submitBody = JSON.parse(String(init.body || "{}")); + return new Response(JSON.stringify({ links: { result: "https://poll.example/job/img1" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (stringUrl === "https://poll.example/job/img1") { + return new Response( + JSON.stringify({ + status: "COMPLETED", + outputs: [{ image: { presignedUrl: "https://cdn.example/edited-out.png" } }], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + throw new Error(`Unexpected URL: ${stringUrl}`); + }; + + const response = await imageEditRoute.POST( + new Request("http://localhost/api/v1/images/edits", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "adobe-firefly/nano-banana-pro", + prompt: "combine these two references", + images: [REF_A, REF_B], + }), + }) + ); + const body = (await response.json()) as ImageResponseBody; + + assert.equal(response.status, 200); + assert.equal(body.data[0].url, "https://cdn.example/edited-out.png"); + + // referenceBlobs upload path: both distinct reference images must have been uploaded + // to Firefly storage and forwarded as referenceBlobs on the generate-async dispatch. + assert.equal(uploadedIds.length, 2, "both reference images must be uploaded individually"); + assert.ok(submitBody, "generate-async must have been called"); + const referenceBlobs = (submitBody as Record).referenceBlobs as Array<{ + id: string; + }>; + assert.ok(Array.isArray(referenceBlobs), "generate-async payload must carry referenceBlobs"); + assert.deepEqual( + referenceBlobs.map((r) => r.id).sort(), + [...uploadedIds].sort() + ); +}); + +test("#8510 v1 image edit POST rejects more than 4 Adobe Firefly reference images", async () => { + await seedAdobeFireflyConnection(); + globalThis.fetch = async () => { + throw new Error("Over-cap Adobe Firefly reference sets must not reach upstream"); + }; + + const images = Array.from({ length: 5 }, (_, i) => dataUrlPng([0x89, 0x50, 0x4e, 0x47, i + 1])); + const response = await imageEditRoute.POST( + new Request("http://localhost/api/v1/images/edits", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "adobe-firefly/nano-banana-pro", + prompt: "combine these references", + images, + }), + }) + ); + const body = (await response.json()) as ErrorResponseBody; + + assert.equal(response.status, 400); + assert.match(body.error.message, /Adobe Firefly image edit supports at most 4 reference images/); + // Hard Rule #12 — every error response routes through buildErrorBody/sanitizeErrorMessage + // and must never leak a raw stack trace. + assert.ok(!body.error.message.includes("at /")); +}); + +test("#8510 v1 image edit POST surfaces missing Adobe Firefly credentials", async () => { + // No adobe-firefly connection seeded at all. + globalThis.fetch = async () => { + throw new Error("Missing-credentials path must not reach upstream"); + }; + + const response = await imageEditRoute.POST( + new Request("http://localhost/api/v1/images/edits", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "adobe-firefly/nano-banana-pro", + prompt: "edit this", + images: [REF_A], + }), + }) + ); + const body = (await response.json()) as ErrorResponseBody; + + assert.equal(response.status, 401); + assert.match(body.error.message, /No credentials for provider: adobe-firefly/); + assert.ok(!body.error.message.includes("at /")); +}); + +test("#8510 v1 image edit POST surfaces Adobe Firefly rate-limit sentinel", async () => { + await seedAdobeFireflyConnection({ rateLimitedUntil: new Date(Date.now() + 60_000).toISOString() }); + globalThis.fetch = async () => { + throw new Error("Rate-limited path must not reach upstream"); + }; + + const response = await imageEditRoute.POST( + new Request("http://localhost/api/v1/images/edits", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "adobe-firefly/nano-banana-pro", + prompt: "edit this", + images: [REF_A], + }), + }) + ); + const body = (await response.json()) as ErrorResponseBody; + + assert.equal(response.status, 429); + assert.match(body.error.message, /All accounts rate limited/); + assert.ok(!body.error.message.includes("at /")); +});