Skip to content

Commit 73721a1

Browse files
authored
Merge pull request #2851 from firebase/next
Release 18/05/26
2 parents 6fe583b + 5d3a5a6 commit 73721a1

9 files changed

Lines changed: 709 additions & 203 deletions

File tree

storage-resize-images/CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
## Version 0.3.5
2+
3+
fix - when a custom filter prompt is configured, the content filter no longer silently fails open on Gemini 2.5 Flash safety refusals. If Gemini's input-side safety declines to respond on borderline imagery (returning empty content rather than `finishReason="SAFETY"`), the resulting genkit schema-validation error is now treated as an implicit block instead of being retried 3 times and propagating as a generic filter error. Installs that only set `CONTENT_FILTER_LEVEL` (no custom prompt) were not affected by this path.
4+
5+
fix - any genkit schema-validation failure on the moderation response (not only empty-content safety refusals) is now treated as a content block instead of being retried and surfaced as a generic error.
6+
7+
fix - blocked images are now routed to the failed-image path before placeholder substitution, so the original blocked content is preserved rather than overwritten by the placeholder.
8+
9+
fix - placeholder swap operates on a copy of the original file, so resizing a blocked image produces placeholder-derived outputs without mutating the stored original.
10+
11+
fix - moderation requests now use the uploaded object's content type when constructing the data URL instead of guessing from the file extension.
12+
113
## Version 0.3.4
214

315
chore: bump dependencies

storage-resize-images/extension.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
# limitations under the License.
1414

1515
name: storage-resize-images
16-
version: 0.3.4
16+
version: 0.3.5
1717
specVersion: v1beta
1818

1919
displayName: Resize Images

storage-resize-images/functions/__tests__/content-filter.test.ts

Lines changed: 122 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { checkImageContent } from "../src/content-filter"; // Update this import path
1+
import { checkImageContent } from "../src/content-filter";
22
import * as path from "path";
33
import { HarmBlockThreshold } from "@google-cloud/vertexai";
4+
import { ValidationError } from "@genkit-ai/core/schema";
45

56
// Mock genkit module
67
jest.mock("genkit", () => ({
@@ -18,21 +19,16 @@ jest.mock("@genkit-ai/vertexai", () => ({
1819
gemini: jest.fn((version: string) => ({ name: `vertexai/${version}` })),
1920
}));
2021

21-
// Mock the sleep function to avoid actual waiting in tests
22-
jest.mock("fs", () => ({
23-
readFileSync: jest.fn().mockReturnValue(Buffer.from("mockImageData")),
24-
}));
25-
26-
jest.mock("mime", () => ({
27-
lookup: jest.fn().mockReturnValue("image/png"),
28-
}));
22+
// Mock logs so we can assert on which filter-blocked log fired.
23+
jest.mock("../src/logs");
2924

3025
describe("checkImageContent with mocks", () => {
3126
// Test image path - using the same path as in your original test suite
3227
const imagePath = path.join(__dirname, "gun-image.png");
3328

3429
// Import mocked modules after they've been mocked
3530
const { genkit } = require("genkit");
31+
const log = require("../src/logs");
3632

3733
beforeEach(() => {
3834
// Reset all mocks before each test
@@ -264,4 +260,121 @@ describe("checkImageContent with mocks", () => {
264260
expect(mockGenerate).toHaveBeenCalled();
265261
expect(result).toBe(true);
266262
});
263+
264+
// Schema used by checkImageContent's custom-prompt path — the moderation
265+
// call sets output: { schema: z.object({ response: z.string() }) }. The
266+
// genkit-emitted JSON schema is replicated here so the real
267+
// ValidationError ctor receives a faithful `schema` field.
268+
const moderationSchema = {
269+
type: "object",
270+
properties: { response: { type: "string" } },
271+
required: ["response"],
272+
additionalProperties: true,
273+
$schema: "http://json-schema.org/draft-07/schema#",
274+
};
275+
276+
it("should return false when genkit throws ValidationError with null-content data (Bug 1)", async () => {
277+
// Reproduces one failure shape Gemini 2.5 Flash + genkit produces
278+
// when input-side safety refuses: empty content → parseSchema(null).
279+
// Instantiated via the real class so the test fails loudly if genkit
280+
// ever changes the ValidationError contract.
281+
const validationError = new ValidationError({
282+
data: null,
283+
errors: [{ path: "(root)", message: "must be object" }],
284+
schema: moderationSchema,
285+
});
286+
287+
const mockGenerate = jest.fn().mockRejectedValue(validationError);
288+
genkit.mockImplementation(() => ({ generate: mockGenerate }));
289+
290+
const result = await checkImageContent(
291+
imagePath,
292+
HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
293+
"Is this image inappropriate?",
294+
"image/png"
295+
);
296+
297+
expect(result).toBe(false);
298+
expect(log.contentFilterBlocked).toHaveBeenCalled();
299+
// Deterministic refusal — must not burn retries.
300+
expect(mockGenerate).toHaveBeenCalledTimes(1);
301+
});
302+
303+
it("should return false when genkit throws ValidationError with empty-object data (Bug 1 variant)", async () => {
304+
// The other observed safety-refusal manifestation, confirmed against
305+
// a real borderline image: extractJson() returns {} when the model
306+
// emits non-JSON or empty refusal text, and the schema rejects it for
307+
// missing the required `response` field.
308+
const validationError = new ValidationError({
309+
data: {},
310+
errors: [
311+
{ path: "(root)", message: "must have required property 'response'" },
312+
],
313+
schema: moderationSchema,
314+
});
315+
316+
const mockGenerate = jest.fn().mockRejectedValue(validationError);
317+
genkit.mockImplementation(() => ({ generate: mockGenerate }));
318+
319+
const result = await checkImageContent(
320+
imagePath,
321+
HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
322+
"Is this image inappropriate?",
323+
"image/png"
324+
);
325+
326+
expect(result).toBe(false);
327+
expect(log.contentFilterBlocked).toHaveBeenCalled();
328+
expect(mockGenerate).toHaveBeenCalledTimes(1);
329+
});
330+
331+
it("should also block on type-mismatch ValidationError (any moderation schema failure → block)", async () => {
332+
// We control the prompt and schema in this code path, so the only
333+
// source of ValidationError is the model's response. The intentional
334+
// policy is: any schema-validation failure means the model didn't
335+
// produce a usable verdict, which on borderline imagery is almost
336+
// always an input-side safety refusal. Failing open is worse than
337+
// a false-positive block, so we treat the whole class as blocked.
338+
const validationError = new ValidationError({
339+
data: { response: 42 },
340+
errors: [{ path: "response", message: "must be string" }],
341+
schema: moderationSchema,
342+
});
343+
344+
const mockGenerate = jest.fn().mockRejectedValue(validationError);
345+
genkit.mockImplementation(() => ({ generate: mockGenerate }));
346+
347+
const result = await checkImageContent(
348+
imagePath,
349+
HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
350+
"prompt",
351+
"image/png"
352+
);
353+
354+
expect(result).toBe(false);
355+
expect(log.contentFilterBlocked).toHaveBeenCalled();
356+
expect(mockGenerate).toHaveBeenCalledTimes(1);
357+
});
358+
359+
it("should still rethrow non-ValidationError errors after exhausting retries", async () => {
360+
// Sanity check: errors WITHOUT the ValidationError shape (status +
361+
// detail.errors) still go through the retry path and propagate.
362+
const networkError = new Error("ECONNRESET");
363+
364+
const mockGenerate = jest.fn().mockRejectedValue(networkError);
365+
genkit.mockImplementation(() => ({ generate: mockGenerate }));
366+
367+
await expect(
368+
checkImageContent(
369+
imagePath,
370+
HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
371+
"prompt",
372+
"image/png",
373+
3
374+
)
375+
).rejects.toThrow("ECONNRESET");
376+
377+
expect(mockGenerate).toHaveBeenCalledTimes(3);
378+
expect(log.contentFilterBlocked).not.toHaveBeenCalled();
379+
}, 30000);
267380
});
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* Live integration tests for checkImageContent — these hit the real
3+
* Vertex AI Gemini 2.5 Flash API. They are skipped by default and only
4+
* run when RUN_LIVE_CONTENT_FILTER_TESTS=true.
5+
*
6+
* Run locally:
7+
* gcloud auth application-default login
8+
* GCLOUD_PROJECT=<your-project> \
9+
* npm run test:live-content-filter --prefix storage-resize-images/functions
10+
*
11+
* The Bug 1 regression test additionally requires LIVE_BORDERLINE_IMAGE_PATH
12+
* pointing to a borderline-NSFW image that triggers Gemini's input-side
13+
* safety refusal. That image cannot be checked into a public repo, so
14+
* developers/CI supply their own. Without the env var, that test is
15+
* skipped (the suite still runs the safe-image and weapon-image cases).
16+
*/
17+
18+
import * as path from "path";
19+
import { checkImageContent } from "../../src/content-filter";
20+
21+
function guessContentType(filePath: string): string {
22+
const ext = path.extname(filePath).toLowerCase();
23+
switch (ext) {
24+
case ".png":
25+
return "image/png";
26+
case ".jpg":
27+
case ".jpeg":
28+
return "image/jpeg";
29+
case ".webp":
30+
return "image/webp";
31+
case ".gif":
32+
return "image/gif";
33+
default:
34+
return "application/octet-stream";
35+
}
36+
}
37+
38+
const runLive = process.env.RUN_LIVE_CONTENT_FILTER_TESTS === "true";
39+
const describeLive = runLive ? describe : describe.skip;
40+
41+
describeLive(
42+
"checkImageContent (live, hits Vertex AI Gemini 2.5 Flash)",
43+
() => {
44+
jest.setTimeout(60_000); // first-call cold start can be 20s+
45+
46+
// test-jpg.jpg is the word "test" rendered as text — has actual content
47+
// for Gemini to evaluate. test-image.png is a tiny black square which
48+
// can trip BLOCK_LOW_AND_ABOVE simply because the model has nothing
49+
// to be confident about.
50+
const safeImagePath = path.join(__dirname, "..", "test-jpg.jpg");
51+
const weaponImagePath = path.join(__dirname, "..", "gun-image.png");
52+
53+
const weaponPrompt =
54+
"Does this image contain a weapon (e.g. firearm, knife, explosive)? " +
55+
"Answer 'yes' if it does, otherwise 'no'.";
56+
57+
test("safe everyday image passes with BLOCK_ONLY_HIGH", async () => {
58+
// BLOCK_ONLY_HIGH is the most permissive non-disabled threshold;
59+
// BLOCK_LOW_AND_ABOVE can over-trigger on synthetic test fixtures
60+
// (the no-prompt path uses 1-output-token, so the model has very
61+
// little room to indicate "this is fine").
62+
const result = await checkImageContent(
63+
safeImagePath,
64+
"BLOCK_ONLY_HIGH",
65+
null,
66+
"image/jpeg"
67+
);
68+
expect(result).toBe(true);
69+
});
70+
71+
test("weapon image + weapon-detection custom prompt is blocked", async () => {
72+
const result = await checkImageContent(
73+
weaponImagePath,
74+
"BLOCK_LOW_AND_ABOVE",
75+
weaponPrompt,
76+
"image/png"
77+
);
78+
expect(result).toBe(false);
79+
});
80+
81+
test("safe image + weapon-detection custom prompt is NOT blocked", async () => {
82+
// Guards against the model over-triggering on unrelated content.
83+
const result = await checkImageContent(
84+
safeImagePath,
85+
"BLOCK_LOW_AND_ABOVE",
86+
weaponPrompt,
87+
"image/jpeg"
88+
);
89+
expect(result).toBe(true);
90+
});
91+
92+
const borderlinePath = process.env.LIVE_BORDERLINE_IMAGE_PATH;
93+
const borderlineDescribe = borderlinePath ? describe : describe.skip;
94+
const borderlineContentType = borderlinePath
95+
? guessContentType(borderlinePath)
96+
: "application/octet-stream";
97+
98+
borderlineDescribe(
99+
"Bug 1 regression — borderline image (LIVE_BORDERLINE_IMAGE_PATH set)",
100+
() => {
101+
const moderationPrompt =
102+
"You are classifying images for a family-friendly platform. " +
103+
"Decide whether this image depicts intimate, romantic, or " +
104+
"suggestive content. Answer 'yes' if any is present, otherwise 'no'.";
105+
106+
test("borderline image refused by Gemini safety returns false (not thrown)", async () => {
107+
// Without the fix, Gemini returns null content → genkit throws
108+
// ValidationError → 3 retries → propagates as filterErrored.
109+
// With the fix, the null-content shape is recognised as a block.
110+
const result = await checkImageContent(
111+
borderlinePath as string,
112+
"BLOCK_LOW_AND_ABOVE",
113+
moderationPrompt,
114+
borderlineContentType
115+
);
116+
expect(result).toBe(false);
117+
});
118+
119+
test("same image with BLOCK_NONE still passes (threshold is honoured)", async () => {
120+
const result = await checkImageContent(
121+
borderlinePath as string,
122+
"BLOCK_NONE",
123+
null,
124+
borderlineContentType
125+
);
126+
expect(result).toBe(true);
127+
});
128+
}
129+
);
130+
}
131+
);

0 commit comments

Comments
 (0)