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
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts";
import { ProductWebhookV1 } from "./product-webhook.ts";

Deno.test("ProductWebhookV1 rejects variation sem shape mínima", () => {
const payload = {
action: "upsert",
product: {
sku: "SKU-1",
name: "Produto",
price: 10,
variations: ["sem-objeto"],
},
};

const result = ProductWebhookV1.safeParse(payload);
assertEquals(result.success, false);
});

Deno.test("ProductWebhookV1 rejects variation sem identificador", () => {
const payload = {
action: "upsert",
product: {
sku: "SKU-1",
name: "Produto",
price: 10,
variations: [{ color: "red" }],
},
};

const result = ProductWebhookV1.safeParse(payload);
assertEquals(result.success, false);
});

Deno.test("ProductWebhookV1 rejects metadata com payload massivo", () => {
const metadata = Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`k${i}`, i]));

const payload = {
action: "upsert",
product: {
sku: "SKU-1",
name: "Produto",
price: 10,
metadata,
},
};

const result = ProductWebhookV1.safeParse(payload);
assertEquals(result.success, false);
});

Deno.test("ProductWebhookV1 rejects metadata com array gigante", () => {
const payload = {
action: "upsert",
product: {
sku: "SKU-1",
name: "Produto",
price: 10,
metadata: {
huge: Array.from({ length: 101 }, (_, i) => i),
},
},
};

const result = ProductWebhookV1.safeParse(payload);
assertEquals(result.success, false);
});
59 changes: 57 additions & 2 deletions supabase/functions/_shared/contracts/schemas/product-webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,46 @@

import { z } from "https://esm.sh/zod@3.23.8";

const JsonPrimitive = z.union([z.string(), z.number(), z.boolean(), z.null()]);
type JsonValue = z.infer<typeof JsonPrimitive> | { [key: string]: JsonValue } | JsonValue[];

const JsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
z.union([
JsonPrimitive,
z.array(JsonValueSchema).max(100),
z.record(z.string().max(100), JsonValueSchema).superRefine((obj, ctx) => {
if (Object.keys(obj).length > 100) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Object must have at most 100 keys",
});
}
}),
])
);

const VariationSchema = z.unknown().superRefine((value, ctx) => {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Variation must be an object" });
return;
}
const record = value as Record<string, unknown>;
const keys = Object.keys(record);
if (keys.length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Variation must not be empty" });
}
if (keys.length > 30) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Variation has too many keys" });
}
const candidateId = record.id ?? record.external_id ?? record.sku;
if (typeof candidateId !== "string" || candidateId.trim().length === 0 || candidateId.length > 255) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Variation must include id/external_id/sku as non-empty string up to 255 chars",
});
}
});

// ---------------------------------------------------------------------------
// v1 (compatível com produção)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -55,8 +95,23 @@ const ProductV1 = z.object({
)
.max(50)
.optional(),
variations: z.array(z.any()).max(200).optional(),
metadata: z.record(z.any()).optional(),
variations: z.array(VariationSchema).max(200).optional(),
metadata: z.unknown().superRefine((value, ctx) => {
if (value === undefined) return;
const parsed = z.record(z.string().max(100), JsonValueSchema).safeParse(value);
if (!parsed.success) {
for (const issue of parsed.error.issues) {
ctx.addIssue(issue);
}
return;
}
if (Object.keys(parsed.data).length > 100) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "metadata must have at most 100 keys",
});
}
}).optional(),
});

export const ProductWebhookV1 = z.object({
Expand Down
6 changes: 4 additions & 2 deletions supabase/functions/product-webhook/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4";
import type { SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2.49.4";
import { buildPublicCorsHeaders } from "../_shared/cors.ts";
import {
parseContract,
Expand All @@ -8,6 +9,7 @@ import {
type ProductWebhookV1Payload,
type ProductWebhookV2Payload,
} from "../_shared/contracts/schemas/product-webhook.ts";
import type { Database } from "../../src/integrations/supabase/types.ts";

const corsHeaders = buildPublicCorsHeaders({ extraAllowHeaders: ["x-webhook-secret", "accept-version"] });

Expand All @@ -25,7 +27,7 @@ Deno.serve(async (req) => {
return new Response(null, { headers: corsHeaders });
}

const supabase = createClient(supabaseUrl, supabaseServiceKey);
const supabase = createClient<Database>(supabaseUrl, supabaseServiceKey);

try {
// Webhook secret check (mantido idêntico)
Expand Down Expand Up @@ -148,7 +150,7 @@ Deno.serve(async (req) => {
});

async function upsertProducts(
supabase: any,
supabase: SupabaseClient<Database>,
products: ProductPayload[],
): Promise<{ created: number; updated: number; failed: number; errors: string[] }> {
let created = 0;
Expand Down
Loading