Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions storage-resize-images/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
## Version 0.3.5

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.

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.

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.

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.

fix - moderation requests now use the uploaded object's content type when constructing the data URL instead of guessing from the file extension.

## Version 0.3.4

chore: bump dependencies
Expand Down
2 changes: 1 addition & 1 deletion storage-resize-images/extension.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# limitations under the License.

name: storage-resize-images
version: 0.3.4
version: 0.3.5
specVersion: v1beta

displayName: Resize Images
Expand Down
131 changes: 122 additions & 9 deletions storage-resize-images/functions/__tests__/content-filter.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { checkImageContent } from "../src/content-filter"; // Update this import path
import { checkImageContent } from "../src/content-filter";
import * as path from "path";
import { HarmBlockThreshold } from "@google-cloud/vertexai";
import { ValidationError } from "@genkit-ai/core/schema";

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

// Mock the sleep function to avoid actual waiting in tests
jest.mock("fs", () => ({
readFileSync: jest.fn().mockReturnValue(Buffer.from("mockImageData")),
}));

jest.mock("mime", () => ({
lookup: jest.fn().mockReturnValue("image/png"),
}));
// Mock logs so we can assert on which filter-blocked log fired.
jest.mock("../src/logs");

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

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

beforeEach(() => {
// Reset all mocks before each test
Expand Down Expand Up @@ -264,4 +260,121 @@ describe("checkImageContent with mocks", () => {
expect(mockGenerate).toHaveBeenCalled();
expect(result).toBe(true);
});

// Schema used by checkImageContent's custom-prompt path — the moderation
// call sets output: { schema: z.object({ response: z.string() }) }. The
// genkit-emitted JSON schema is replicated here so the real
// ValidationError ctor receives a faithful `schema` field.
const moderationSchema = {
type: "object",
properties: { response: { type: "string" } },
required: ["response"],
additionalProperties: true,
$schema: "http://json-schema.org/draft-07/schema#",
};

it("should return false when genkit throws ValidationError with null-content data (Bug 1)", async () => {
// Reproduces one failure shape Gemini 2.5 Flash + genkit produces
// when input-side safety refuses: empty content → parseSchema(null).
// Instantiated via the real class so the test fails loudly if genkit
// ever changes the ValidationError contract.
const validationError = new ValidationError({
data: null,
errors: [{ path: "(root)", message: "must be object" }],
schema: moderationSchema,
});

const mockGenerate = jest.fn().mockRejectedValue(validationError);
genkit.mockImplementation(() => ({ generate: mockGenerate }));

const result = await checkImageContent(
imagePath,
HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
"Is this image inappropriate?",
"image/png"
);

expect(result).toBe(false);
expect(log.contentFilterBlocked).toHaveBeenCalled();
// Deterministic refusal — must not burn retries.
expect(mockGenerate).toHaveBeenCalledTimes(1);
});

it("should return false when genkit throws ValidationError with empty-object data (Bug 1 variant)", async () => {
// The other observed safety-refusal manifestation, confirmed against
// a real borderline image: extractJson() returns {} when the model
// emits non-JSON or empty refusal text, and the schema rejects it for
// missing the required `response` field.
const validationError = new ValidationError({
data: {},
errors: [
{ path: "(root)", message: "must have required property 'response'" },
],
schema: moderationSchema,
});

const mockGenerate = jest.fn().mockRejectedValue(validationError);
genkit.mockImplementation(() => ({ generate: mockGenerate }));

const result = await checkImageContent(
imagePath,
HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
"Is this image inappropriate?",
"image/png"
);

expect(result).toBe(false);
expect(log.contentFilterBlocked).toHaveBeenCalled();
expect(mockGenerate).toHaveBeenCalledTimes(1);
});

it("should also block on type-mismatch ValidationError (any moderation schema failure → block)", async () => {
// We control the prompt and schema in this code path, so the only
// source of ValidationError is the model's response. The intentional
// policy is: any schema-validation failure means the model didn't
// produce a usable verdict, which on borderline imagery is almost
// always an input-side safety refusal. Failing open is worse than
// a false-positive block, so we treat the whole class as blocked.
const validationError = new ValidationError({
data: { response: 42 },
errors: [{ path: "response", message: "must be string" }],
schema: moderationSchema,
});

const mockGenerate = jest.fn().mockRejectedValue(validationError);
genkit.mockImplementation(() => ({ generate: mockGenerate }));

const result = await checkImageContent(
imagePath,
HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
"prompt",
"image/png"
);

expect(result).toBe(false);
expect(log.contentFilterBlocked).toHaveBeenCalled();
expect(mockGenerate).toHaveBeenCalledTimes(1);
});

it("should still rethrow non-ValidationError errors after exhausting retries", async () => {
// Sanity check: errors WITHOUT the ValidationError shape (status +
// detail.errors) still go through the retry path and propagate.
const networkError = new Error("ECONNRESET");

const mockGenerate = jest.fn().mockRejectedValue(networkError);
genkit.mockImplementation(() => ({ generate: mockGenerate }));

await expect(
checkImageContent(
imagePath,
HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
"prompt",
"image/png",
3
)
).rejects.toThrow("ECONNRESET");

expect(mockGenerate).toHaveBeenCalledTimes(3);
expect(log.contentFilterBlocked).not.toHaveBeenCalled();
}, 30000);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Live integration tests for checkImageContent — these hit the real
* Vertex AI Gemini 2.5 Flash API. They are skipped by default and only
* run when RUN_LIVE_CONTENT_FILTER_TESTS=true.
*
* Run locally:
* gcloud auth application-default login
* GCLOUD_PROJECT=<your-project> \
* npm run test:live-content-filter --prefix storage-resize-images/functions
*
* The Bug 1 regression test additionally requires LIVE_BORDERLINE_IMAGE_PATH
* pointing to a borderline-NSFW image that triggers Gemini's input-side
* safety refusal. That image cannot be checked into a public repo, so
* developers/CI supply their own. Without the env var, that test is
* skipped (the suite still runs the safe-image and weapon-image cases).
*/

import * as path from "path";
import { checkImageContent } from "../../src/content-filter";

function guessContentType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case ".png":
return "image/png";
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".webp":
return "image/webp";
case ".gif":
return "image/gif";
default:
return "application/octet-stream";
}
}

const runLive = process.env.RUN_LIVE_CONTENT_FILTER_TESTS === "true";
const describeLive = runLive ? describe : describe.skip;

describeLive(
"checkImageContent (live, hits Vertex AI Gemini 2.5 Flash)",
() => {
jest.setTimeout(60_000); // first-call cold start can be 20s+

// test-jpg.jpg is the word "test" rendered as text — has actual content
// for Gemini to evaluate. test-image.png is a tiny black square which
// can trip BLOCK_LOW_AND_ABOVE simply because the model has nothing
// to be confident about.
const safeImagePath = path.join(__dirname, "..", "test-jpg.jpg");
const weaponImagePath = path.join(__dirname, "..", "gun-image.png");

const weaponPrompt =
"Does this image contain a weapon (e.g. firearm, knife, explosive)? " +
"Answer 'yes' if it does, otherwise 'no'.";

test("safe everyday image passes with BLOCK_ONLY_HIGH", async () => {
// BLOCK_ONLY_HIGH is the most permissive non-disabled threshold;
// BLOCK_LOW_AND_ABOVE can over-trigger on synthetic test fixtures
// (the no-prompt path uses 1-output-token, so the model has very
// little room to indicate "this is fine").
const result = await checkImageContent(
safeImagePath,
"BLOCK_ONLY_HIGH",
null,
"image/jpeg"
);
expect(result).toBe(true);
});

test("weapon image + weapon-detection custom prompt is blocked", async () => {
const result = await checkImageContent(
weaponImagePath,
"BLOCK_LOW_AND_ABOVE",
weaponPrompt,
"image/png"
);
expect(result).toBe(false);
});

test("safe image + weapon-detection custom prompt is NOT blocked", async () => {
// Guards against the model over-triggering on unrelated content.
const result = await checkImageContent(
safeImagePath,
"BLOCK_LOW_AND_ABOVE",
weaponPrompt,
"image/jpeg"
);
expect(result).toBe(true);
});

const borderlinePath = process.env.LIVE_BORDERLINE_IMAGE_PATH;
const borderlineDescribe = borderlinePath ? describe : describe.skip;
const borderlineContentType = borderlinePath
? guessContentType(borderlinePath)
: "application/octet-stream";

borderlineDescribe(
"Bug 1 regression — borderline image (LIVE_BORDERLINE_IMAGE_PATH set)",
() => {
const moderationPrompt =
"You are classifying images for a family-friendly platform. " +
"Decide whether this image depicts intimate, romantic, or " +
"suggestive content. Answer 'yes' if any is present, otherwise 'no'.";

test("borderline image refused by Gemini safety returns false (not thrown)", async () => {
// Without the fix, Gemini returns null content → genkit throws
// ValidationError → 3 retries → propagates as filterErrored.
// With the fix, the null-content shape is recognised as a block.
const result = await checkImageContent(
borderlinePath as string,
"BLOCK_LOW_AND_ABOVE",
moderationPrompt,
borderlineContentType
);
expect(result).toBe(false);
});

test("same image with BLOCK_NONE still passes (threshold is honoured)", async () => {
const result = await checkImageContent(
borderlinePath as string,
"BLOCK_NONE",
null,
borderlineContentType
);
expect(result).toBe(true);
});
}
);
}
);
Loading
Loading