From d7400bb3abe60840ecae53540bb615c9e05583b2 Mon Sep 17 00:00:00 2001 From: Vishal Paliwal Date: Sat, 20 Dec 2025 16:06:25 +0530 Subject: [PATCH 1/2] feat: Implement get_latest_memojis RPC and Edge Function endpoint --- deno.lock | 21 +++++ supabase/functions/ingredicheck/memoji.ts | 41 ++++++++++ ...095459_add_get_latest_memojis_function.sql | 26 ++++++ supabase/schemas/031_memoji_functions.sql | 28 +++++++ supabase/tests/EndToEndTests/memoji.test.ts | 82 +++++++++++++++++++ 5 files changed, 198 insertions(+) create mode 100644 supabase/migrations/20251220095459_add_get_latest_memojis_function.sql create mode 100644 supabase/tests/EndToEndTests/memoji.test.ts diff --git a/deno.lock b/deno.lock index 18a982e..0557666 100644 --- a/deno.lock +++ b/deno.lock @@ -15,9 +15,15 @@ } }, "redirects": { + "https://esm.sh/@supabase/functions-js@^2.0.0?target=denonext": "https://esm.sh/@supabase/functions-js@2.89.0?target=denonext", + "https://esm.sh/@supabase/gotrue-js@^2.0.1?target=denonext": "https://esm.sh/@supabase/gotrue-js@2.89.0?target=denonext", "https://esm.sh/@supabase/node-fetch@^2.6.14?target=denonext": "https://esm.sh/@supabase/node-fetch@2.6.15?target=denonext", + "https://esm.sh/@supabase/postgrest-js@^1.0.0?target=denonext": "https://esm.sh/@supabase/postgrest-js@1.21.4?target=denonext", + "https://esm.sh/@supabase/realtime-js@^2.0.0?target=denonext": "https://esm.sh/@supabase/realtime-js@2.89.0?target=denonext", + "https://esm.sh/@supabase/storage-js@^2.0.0?target=denonext": "https://esm.sh/@supabase/storage-js@2.89.0?target=denonext", "https://esm.sh/@types/ws@~8.18.1/index.d.mts": "https://esm.sh/@types/ws@8.18.1/index.d.mts", "https://esm.sh/bufferutil@^4.0.1?target=denonext": "https://esm.sh/bufferutil@4.0.9?target=denonext", + "https://esm.sh/iceberg-js@^0.8.1?target=denonext": "https://esm.sh/iceberg-js@0.8.1?target=denonext", "https://esm.sh/node-gyp-build@^4.3.0?target=denonext": "https://esm.sh/node-gyp-build@4.8.4?target=denonext", "https://esm.sh/tr46@~0.0.3?target=denonext": "https://esm.sh/tr46@0.0.3?target=denonext", "https://esm.sh/utf-8-validate@%3E=5.0.2?target=denonext": "https://esm.sh/utf-8-validate@6.0.5?target=denonext", @@ -110,19 +116,34 @@ "https://deno.land/std@0.224.0/path/windows/to_namespaced_path.ts": "4ffa4fb6fae321448d5fe810b3ca741d84df4d7897e61ee29be961a6aac89a4c", "https://esm.sh/@supabase/auth-js@2.65.0/denonext/auth-js.mjs": "b0dae93271b199b26f7990e9091ecd2556125f2bfe1c9a443893bf0519908858", "https://esm.sh/@supabase/functions-js@2.4.1/denonext/functions-js.mjs": "0dfa81270bba8ae5af371b273a564203b4625b3a18e70774235b0b46ef40d940", + "https://esm.sh/@supabase/functions-js@2.89.0/denonext/functions-js.mjs": "ad96f85b7c776e988d2990d16a6c53e7996545dfe5b8559dbfb9927b793e608d", + "https://esm.sh/@supabase/functions-js@2.89.0?target=denonext": "d60cd4c8cab2710e87ae2fd48db3664f519fb8185145000c9fae3d299e3ad6f2", + "https://esm.sh/@supabase/gotrue-js@2.89.0/denonext/gotrue-js.mjs": "abcfa8b847238a4d0fb92540c60643098450c3d1b39b7f11c1f35e0bca127f7d", + "https://esm.sh/@supabase/gotrue-js@2.89.0?target=denonext": "8e2a638324018e869a8b720aee100951762fc1a3d826c1b6c31cac7e45ea7354", "https://esm.sh/@supabase/node-fetch@2.6.15/denonext/node-fetch.mjs": "0bae9052231f4f6dbccc7234d05ea96923dbf967be12f402764580b6bf9f713d", "https://esm.sh/@supabase/node-fetch@2.6.15?target=denonext": "4d28c4ad97328403184353f68434f2b6973971507919e9150297413664919cf3", "https://esm.sh/@supabase/postgrest-js@1.16.1/denonext/postgrest-js.mjs": "1c62b0354f3bcf923c92a1bce3fea042ec76abc51d3aac4b53957fcb0854428a", + "https://esm.sh/@supabase/postgrest-js@1.21.4/denonext/postgrest-js.mjs": "c3769b11ef02debc78ecf6ab4e152d3cf7dbd05bbbafeb72c160e76cc57cda3c", + "https://esm.sh/@supabase/postgrest-js@1.21.4?target=denonext": "db2315bc0ff19690cd4239c5adb5f5787f5dea04955058ef1fca541d49555ed6", "https://esm.sh/@supabase/realtime-js@2.10.2/denonext/realtime-js.mjs": "3a5a350e881736135ec1bd8c4361c96edeb647d3c56be7cf3a68eddfaf3ce242", + "https://esm.sh/@supabase/realtime-js@2.89.0/denonext/realtime-js.mjs": "e98f81bcfb8fed536a7aeb67d8008c320f465c235107ac69f48dddd861ad2b4e", + "https://esm.sh/@supabase/realtime-js@2.89.0?target=denonext": "2ec69e05862bf64771befbd5fe60ac6352690fab8656ea2c404d433fa0b9940e", "https://esm.sh/@supabase/storage-js@2.7.0/denonext/storage-js.mjs": "e831f9ac91b49f5e7026879efa7b61a360bc1c4b9fa2dd9890432c2e877a6611", + "https://esm.sh/@supabase/storage-js@2.89.0/denonext/storage-js.mjs": "4dbf4d7138af35a46733d2043e58d73c72e1a2dc40738a71f5ea98fb27a3ed55", + "https://esm.sh/@supabase/storage-js@2.89.0?target=denonext": "3d737f47c9d7e597d4a228afdb94929c20f5f6c5957a020672300df58b511051", + "https://esm.sh/@supabase/supabase-js@2.0.0": "9ad3ba5c16116566aed94f54eee127c1a6d25749372d4e078443b60c083a46c0", + "https://esm.sh/@supabase/supabase-js@2.0.0/denonext/supabase-js.mjs": "78bbf2a9dc406c71e2ec5d81c0d464b53724d5b9f405f7b2dcc1de69ea4842e1", "https://esm.sh/@supabase/supabase-js@2.45.4": "1108d69216995335d057dbd15907caaf514a4d1ef082f097050573b9d589a57c", "https://esm.sh/@supabase/supabase-js@2.45.4/denonext/supabase-js.mjs": "8d7c017f03473b9c0f9cd1be611dc51d8c4f359183ece1fcffda731ccc55aba3", "https://esm.sh/bufferutil@4.0.9/denonext/bufferutil.mjs": "13dca4d5bb2c68cbe119f880fa3bd785b9a81a8e02e0834dae604b4b85295cd8", "https://esm.sh/bufferutil@4.0.9?target=denonext": "e32574569ab438facfcc3f412c659b0719bbf05477136ca176938c9a3ac45125", + "https://esm.sh/iceberg-js@0.8.1/denonext/iceberg-js.mjs": "d839d81a2e3966500ca2cdd0c1cb458e9608bacfc91f7bb67a69b2e878dcdb4f", + "https://esm.sh/iceberg-js@0.8.1?target=denonext": "58c849d7fe2bf4eca4a84eb501e83c161a7d8c34ca1bec15a962b3bec3062633", "https://esm.sh/node-gyp-build@4.8.4/denonext/node-gyp-build.mjs": "9a86f2d044fc77bd60aaa3d697c2ba1b818da5fb1b9aaeedec59a40b8e908803", "https://esm.sh/node-gyp-build@4.8.4?target=denonext": "261a6cedf1fdbf159798141ba1e2311ac1510682c5c8b55dacc8cf5fdee4aa06", "https://esm.sh/tr46@0.0.3/denonext/tr46.mjs": "5753ec0a99414f4055f0c1f97691100f13d88e48a8443b00aebb90a512785fa2", "https://esm.sh/tr46@0.0.3?target=denonext": "19cb9be0f0d418a0c3abb81f2df31f080e9540a04e43b0f699bce1149cba0cbb", + "https://esm.sh/tslib@2.8.1/denonext/tslib.mjs": "ebce3cd5facb654623020337f867b426ba95f71596ba87acc9e6c6f4e55905ca", "https://esm.sh/utf-8-validate@6.0.5/denonext/utf-8-validate.mjs": "66b8ea532a0c745068f5b96ddb1bae332c3036703243541d2e89e66331974d98", "https://esm.sh/utf-8-validate@6.0.5?target=denonext": "071bc33ba1a58297e23a34d69dd589fd06df04b0f373b382ff5da544a623f271", "https://esm.sh/webidl-conversions@3.0.1/denonext/webidl-conversions.mjs": "54b5c2d50a294853c4ccebf9d5ed8988c94f4e24e463d84ec859a866ea5fafec", diff --git a/supabase/functions/ingredicheck/memoji.ts b/supabase/functions/ingredicheck/memoji.ts index 5c1dd89..70dc243 100644 --- a/supabase/functions/ingredicheck/memoji.ts +++ b/supabase/functions/ingredicheck/memoji.ts @@ -359,6 +359,47 @@ export function registerMemojiRoutes(router: Router, serviceClient: SupabaseClie ctx.response.body = { error: { message: "Internal error during memoji generation." } }; } }); + + router.get("/ingredicheck/memojis/latest", async (ctx: MemojiContext) => { + const supabase = serviceClient ?? getSupabaseServiceClient(); + const userId = ctx.state.userId as string | undefined; + + if (!userId) { + ctx.response.status = 401; + ctx.response.body = { error: { code: "AUTH_REQUIRED", message: "Sign in required." } }; + return; + } + + const limit = Number(ctx.request.url.searchParams.get("limit") ?? "10"); + const offset = Number(ctx.request.url.searchParams.get("offset") ?? "0"); + + if (isNaN(limit) || limit < 1 || limit > 100) { + ctx.response.status = 400; + ctx.response.body = { error: { message: "Invalid limit parameter." } }; + return; + } + + if (isNaN(offset) || offset < 0) { + ctx.response.status = 400; + ctx.response.body = { error: { message: "Invalid offset parameter." } }; + return; + } + + const { data, error } = await supabase.rpc("get_latest_memojis", { + p_limit: limit, + p_offset: offset, + }); + + if (error) { + console.error("Failed to fetch latest memojis", error); + ctx.response.status = 500; + ctx.response.body = { error: { message: "Internal error fetching memojis." } }; + return; + } + + ctx.response.status = 200; + ctx.response.body = { memojis: data }; + }); } // Test helpers (exported for offline/unit tests) diff --git a/supabase/migrations/20251220095459_add_get_latest_memojis_function.sql b/supabase/migrations/20251220095459_add_get_latest_memojis_function.sql new file mode 100644 index 0000000..e25bdff --- /dev/null +++ b/supabase/migrations/20251220095459_add_get_latest_memojis_function.sql @@ -0,0 +1,26 @@ +set check_function_bodies = off; + +CREATE OR REPLACE FUNCTION public.get_latest_memojis(p_limit integer, p_offset integer) + RETURNS TABLE(id uuid, name text, created_at timestamp with time zone, metadata jsonb) + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'public', 'storage', 'extensions' +AS $function$ +BEGIN + RETURN QUERY + SELECT + o.id, + o.name, + o.created_at, + o.metadata + FROM storage.objects o + WHERE o.bucket_id = 'memoji-images' + ORDER BY o.created_at DESC + LIMIT p_limit OFFSET p_offset; +END; +$function$ +; + +GRANT EXECUTE ON FUNCTION public.get_latest_memojis(integer, integer) TO anon, authenticated, service_role; + + diff --git a/supabase/schemas/031_memoji_functions.sql b/supabase/schemas/031_memoji_functions.sql index 293f7e4..5528f37 100644 --- a/supabase/schemas/031_memoji_functions.sql +++ b/supabase/schemas/031_memoji_functions.sql @@ -17,4 +17,32 @@ BEGIN END; $$; +-- Get latest memojis from storage bucket with pagination +CREATE OR REPLACE FUNCTION public.get_latest_memojis(p_limit int, p_offset int) +RETURNS TABLE ( + id uuid, + name text, + created_at timestamptz, + metadata jsonb +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, storage, extensions +AS $$ +BEGIN + RETURN QUERY + SELECT + o.id, + o.name, + o.created_at, + o.metadata + FROM storage.objects o + WHERE o.bucket_id = 'memoji-images' + ORDER BY o.created_at DESC + LIMIT p_limit OFFSET p_offset; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_latest_memojis(int, int) TO anon, authenticated, service_role; + diff --git a/supabase/tests/EndToEndTests/memoji.test.ts b/supabase/tests/EndToEndTests/memoji.test.ts new file mode 100644 index 0000000..0413f22 --- /dev/null +++ b/supabase/tests/EndToEndTests/memoji.test.ts @@ -0,0 +1,82 @@ + +import { functionsUrl, signInAnon, createSupabaseServiceClient } from "../_shared/utils.ts"; + +function assertEquals(actual: T, expected: T, message?: string): void { + if (!Object.is(actual, expected)) { + throw new Error( + message ?? `Assertion failed: expected ${expected}, received ${actual}`, + ); + } +} + +function assertExists(actual: unknown, message?: string): void { + if (actual === undefined || actual === null) { + throw new Error(message ?? "Expected value to exist"); + } +} + +function assertArray(actual: unknown, message?: string): void { + if (!Array.isArray(actual)) { + throw new Error(message ?? `Expected value to be an array, but got ${typeof actual}`); + } +} + +Deno.test({ + name: "memoji: get_latest_memojis", + sanitizeOps: false, + sanitizeResources: false, + fn: async () => { + const { accessToken, baseUrl } = await signInAnon(); + + // Seed: Upload a dummy file if needed + const supabase = createSupabaseServiceClient({ baseUrl }); + + // Ensure bucket exists + await supabase.storage.createBucket("memoji-images", { public: true }); + + // Upload a test file + const testFileName = `test-${Date.now()}.txt`; + const { error: uploadError } = await supabase.storage + .from("memoji-images") + .upload(testFileName, new Blob(["dummy content"]), { upsert: true }); + + if (uploadError) { + console.warn("Failed to seed dummy file (might already exist or permission issue):", uploadError); + } else { + console.log("Seeded dummy file:", testFileName); + } + + // Call the Edge Function + const response = await fetch(`${functionsUrl(baseUrl)}/ingredicheck/memojis/latest?limit=10&offset=0`, { + method: "GET", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + } + }); + + if (response.status !== 200) { + const text = await response.text(); + throw new Error(`RPC call failed with status ${response.status}: ${text}`); + } + + const data = await response.json(); + + // Assertions + assertExists(data, "Response data should exist"); + assertArray(data.memojis, "Response.memojis should be an array"); + + // We might get an empty array if the bucket is empty, which is fine for this test. + // The main goal is to verify the function exists and is accessible. + console.log(`Fetched ${data.memojis.length} memojis.`); + + // If there are items, verify structure + if (data.memojis.length > 0) { + const first = data.memojis[0]; + assertExists(first.id, "Memoji ID should exist"); + assertExists(first.name, "Memoji name should exist"); + // meta check + // assertExists(first.metadata, "Memoji metadata should exist"); + } + } +}); From 75bba94d13d8c9f5c26e952867d4007b71e36ba0 Mon Sep 17 00:00:00 2001 From: Vishal Paliwal Date: Mon, 22 Dec 2025 15:29:24 +0530 Subject: [PATCH 2/2] fix: Address PR review (security, rate-limit, cleanup, tests) --- deno.lock | 22 +++ supabase/functions/ingredicheck/memoji.ts | 22 ++- ...095459_add_get_latest_memojis_function.sql | 26 ---- .../20251222120000_fix_memoji_rpc.sql | 28 ++++ supabase/schemas/031_memoji_functions.sql | 1 + supabase/tests/EndToEndTests/memoji.test.ts | 147 +++++++++--------- 6 files changed, 148 insertions(+), 98 deletions(-) delete mode 100644 supabase/migrations/20251220095459_add_get_latest_memojis_function.sql create mode 100644 supabase/migrations/20251222120000_fix_memoji_rpc.sql diff --git a/deno.lock b/deno.lock index 0557666..db90b1a 100644 --- a/deno.lock +++ b/deno.lock @@ -16,11 +16,17 @@ }, "redirects": { "https://esm.sh/@supabase/functions-js@^2.0.0?target=denonext": "https://esm.sh/@supabase/functions-js@2.89.0?target=denonext", + "https://esm.sh/@supabase/functions-js@^2.1.5?target=denonext": "https://esm.sh/@supabase/functions-js@2.4.5?target=denonext", "https://esm.sh/@supabase/gotrue-js@^2.0.1?target=denonext": "https://esm.sh/@supabase/gotrue-js@2.89.0?target=denonext", + "https://esm.sh/@supabase/gotrue-js@^2.60.0?target=denonext": "https://esm.sh/@supabase/gotrue-js@2.71.1?target=denonext", + "https://esm.sh/@supabase/node-fetch@^2.6.13?target=denonext": "https://esm.sh/@supabase/node-fetch@2.6.15?target=denonext", "https://esm.sh/@supabase/node-fetch@^2.6.14?target=denonext": "https://esm.sh/@supabase/node-fetch@2.6.15?target=denonext", "https://esm.sh/@supabase/postgrest-js@^1.0.0?target=denonext": "https://esm.sh/@supabase/postgrest-js@1.21.4?target=denonext", + "https://esm.sh/@supabase/postgrest-js@^1.9.0?target=denonext": "https://esm.sh/@supabase/postgrest-js@1.21.3?target=denonext", "https://esm.sh/@supabase/realtime-js@^2.0.0?target=denonext": "https://esm.sh/@supabase/realtime-js@2.89.0?target=denonext", + "https://esm.sh/@supabase/realtime-js@^2.9.3?target=denonext": "https://esm.sh/@supabase/realtime-js@2.15.4?target=denonext", "https://esm.sh/@supabase/storage-js@^2.0.0?target=denonext": "https://esm.sh/@supabase/storage-js@2.89.0?target=denonext", + "https://esm.sh/@supabase/storage-js@^2.5.4?target=denonext": "https://esm.sh/@supabase/storage-js@2.11.0?target=denonext", "https://esm.sh/@types/ws@~8.18.1/index.d.mts": "https://esm.sh/@types/ws@8.18.1/index.d.mts", "https://esm.sh/bufferutil@^4.0.1?target=denonext": "https://esm.sh/bufferutil@4.0.9?target=denonext", "https://esm.sh/iceberg-js@^0.8.1?target=denonext": "https://esm.sh/iceberg-js@0.8.1?target=denonext", @@ -32,6 +38,10 @@ "https://esm.sh/ws@^8.14.2?target=denonext": "https://esm.sh/ws@8.18.3?target=denonext" }, "remote": { + "https://deno.land/std@0.192.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e", + "https://deno.land/std@0.192.0/testing/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", + "https://deno.land/std@0.192.0/testing/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", + "https://deno.land/std@0.192.0/testing/asserts.ts": "e16d98b4d73ffc4ed498d717307a12500ae4f2cbe668f1a215632d19fcffc22f", "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", "https://deno.land/std@0.224.0/dotenv/mod.ts": "0180eaeedaaf88647318811cdaa418cc64dc51fb08354f91f5f480d0a1309f7d", @@ -116,23 +126,35 @@ "https://deno.land/std@0.224.0/path/windows/to_namespaced_path.ts": "4ffa4fb6fae321448d5fe810b3ca741d84df4d7897e61ee29be961a6aac89a4c", "https://esm.sh/@supabase/auth-js@2.65.0/denonext/auth-js.mjs": "b0dae93271b199b26f7990e9091ecd2556125f2bfe1c9a443893bf0519908858", "https://esm.sh/@supabase/functions-js@2.4.1/denonext/functions-js.mjs": "0dfa81270bba8ae5af371b273a564203b4625b3a18e70774235b0b46ef40d940", + "https://esm.sh/@supabase/functions-js@2.4.5/denonext/functions-js.mjs": "acfcb4db90c292c335a9dbb5664485f5e0b680533d68e6942abb9387ec7e0d0b", + "https://esm.sh/@supabase/functions-js@2.4.5?target=denonext": "436c192af672106cd6eda10f518ed49795c21e78af5eb0d7000507e0886c6afa", "https://esm.sh/@supabase/functions-js@2.89.0/denonext/functions-js.mjs": "ad96f85b7c776e988d2990d16a6c53e7996545dfe5b8559dbfb9927b793e608d", "https://esm.sh/@supabase/functions-js@2.89.0?target=denonext": "d60cd4c8cab2710e87ae2fd48db3664f519fb8185145000c9fae3d299e3ad6f2", + "https://esm.sh/@supabase/gotrue-js@2.71.1/denonext/gotrue-js.mjs": "746cca5b920c651d2c60d2163971f6a9153654508ec940a10af5111e38e43c5b", + "https://esm.sh/@supabase/gotrue-js@2.71.1?target=denonext": "182ef71da6bbccf5696027c0ff2df5fae9151d7bc56a5925dd3b7f86c34b56a2", "https://esm.sh/@supabase/gotrue-js@2.89.0/denonext/gotrue-js.mjs": "abcfa8b847238a4d0fb92540c60643098450c3d1b39b7f11c1f35e0bca127f7d", "https://esm.sh/@supabase/gotrue-js@2.89.0?target=denonext": "8e2a638324018e869a8b720aee100951762fc1a3d826c1b6c31cac7e45ea7354", "https://esm.sh/@supabase/node-fetch@2.6.15/denonext/node-fetch.mjs": "0bae9052231f4f6dbccc7234d05ea96923dbf967be12f402764580b6bf9f713d", "https://esm.sh/@supabase/node-fetch@2.6.15?target=denonext": "4d28c4ad97328403184353f68434f2b6973971507919e9150297413664919cf3", "https://esm.sh/@supabase/postgrest-js@1.16.1/denonext/postgrest-js.mjs": "1c62b0354f3bcf923c92a1bce3fea042ec76abc51d3aac4b53957fcb0854428a", + "https://esm.sh/@supabase/postgrest-js@1.21.3/denonext/postgrest-js.mjs": "a9567fcd2c62b498d01514aebf82beb11acdd8242833d081bdc61d1f0227f9ef", + "https://esm.sh/@supabase/postgrest-js@1.21.3?target=denonext": "4beec52a30e4cc9af10ed6ac2016ae00f1595b7601b4649f65c24a114528d372", "https://esm.sh/@supabase/postgrest-js@1.21.4/denonext/postgrest-js.mjs": "c3769b11ef02debc78ecf6ab4e152d3cf7dbd05bbbafeb72c160e76cc57cda3c", "https://esm.sh/@supabase/postgrest-js@1.21.4?target=denonext": "db2315bc0ff19690cd4239c5adb5f5787f5dea04955058ef1fca541d49555ed6", "https://esm.sh/@supabase/realtime-js@2.10.2/denonext/realtime-js.mjs": "3a5a350e881736135ec1bd8c4361c96edeb647d3c56be7cf3a68eddfaf3ce242", + "https://esm.sh/@supabase/realtime-js@2.15.4/denonext/realtime-js.mjs": "014803e3c1776dfe9c0c05c4f890252c66afce791fe1d93ecf687d7e773febcc", + "https://esm.sh/@supabase/realtime-js@2.15.4?target=denonext": "549b58c286f3c19b191faa537ce392cf30452cb3c3c4d4d6471da2e2ac95878c", "https://esm.sh/@supabase/realtime-js@2.89.0/denonext/realtime-js.mjs": "e98f81bcfb8fed536a7aeb67d8008c320f465c235107ac69f48dddd861ad2b4e", "https://esm.sh/@supabase/realtime-js@2.89.0?target=denonext": "2ec69e05862bf64771befbd5fe60ac6352690fab8656ea2c404d433fa0b9940e", + "https://esm.sh/@supabase/storage-js@2.11.0/denonext/storage-js.mjs": "86f098636aef56302e6a7389b8eba859874180b0857661be196c3cce387b6461", + "https://esm.sh/@supabase/storage-js@2.11.0?target=denonext": "d7e8b6651554f79445c88d1beaa8c5e2fcb960a6f67449e4903f532ea3301539", "https://esm.sh/@supabase/storage-js@2.7.0/denonext/storage-js.mjs": "e831f9ac91b49f5e7026879efa7b61a360bc1c4b9fa2dd9890432c2e877a6611", "https://esm.sh/@supabase/storage-js@2.89.0/denonext/storage-js.mjs": "4dbf4d7138af35a46733d2043e58d73c72e1a2dc40738a71f5ea98fb27a3ed55", "https://esm.sh/@supabase/storage-js@2.89.0?target=denonext": "3d737f47c9d7e597d4a228afdb94929c20f5f6c5957a020672300df58b511051", "https://esm.sh/@supabase/supabase-js@2.0.0": "9ad3ba5c16116566aed94f54eee127c1a6d25749372d4e078443b60c083a46c0", "https://esm.sh/@supabase/supabase-js@2.0.0/denonext/supabase-js.mjs": "78bbf2a9dc406c71e2ec5d81c0d464b53724d5b9f405f7b2dcc1de69ea4842e1", + "https://esm.sh/@supabase/supabase-js@2.39.3": "832d7714491bd1c8a9008a332b35cb099f24b451ad79e755ecf4d71985fd6610", + "https://esm.sh/@supabase/supabase-js@2.39.3/denonext/supabase-js.mjs": "054f4cd1560fa814cdc10d06c0809b00d0817dc06a072fa983e4c023e4346439", "https://esm.sh/@supabase/supabase-js@2.45.4": "1108d69216995335d057dbd15907caaf514a4d1ef082f097050573b9d589a57c", "https://esm.sh/@supabase/supabase-js@2.45.4/denonext/supabase-js.mjs": "8d7c017f03473b9c0f9cd1be611dc51d8c4f359183ece1fcffda731ccc55aba3", "https://esm.sh/bufferutil@4.0.9/denonext/bufferutil.mjs": "13dca4d5bb2c68cbe119f880fa3bd785b9a81a8e02e0834dae604b4b85295cd8", diff --git a/supabase/functions/ingredicheck/memoji.ts b/supabase/functions/ingredicheck/memoji.ts index 70dc243..987da61 100644 --- a/supabase/functions/ingredicheck/memoji.ts +++ b/supabase/functions/ingredicheck/memoji.ts @@ -361,7 +361,17 @@ export function registerMemojiRoutes(router: Router, serviceClient: SupabaseClie }); router.get("/ingredicheck/memojis/latest", async (ctx: MemojiContext) => { - const supabase = serviceClient ?? getSupabaseServiceClient(); + const clientIP = ctx.request.headers.get("x-forwarded-for") ?? + ctx.request.ip ?? + "unknown"; + + const rate = checkRateLimit(clientIP ?? "unknown"); + if (!rate.allowed) { + ctx.response.status = 429; + ctx.response.body = { error: { message: "Rate limit exceeded." } }; + return; + } + const userId = ctx.state.userId as string | undefined; if (!userId) { @@ -370,6 +380,14 @@ export function registerMemojiRoutes(router: Router, serviceClient: SupabaseClie return; } + // Create a user-scoped client to ensure auth.uid() works in the DB function + const authHeader = ctx.request.headers.get("Authorization") ?? ""; + const userClient = createClient( + Deno.env.get("SUPABASE_URL") ?? "", + Deno.env.get("SUPABASE_ANON_KEY") ?? "", + { global: { headers: { Authorization: authHeader } } } + ); + const limit = Number(ctx.request.url.searchParams.get("limit") ?? "10"); const offset = Number(ctx.request.url.searchParams.get("offset") ?? "0"); @@ -385,7 +403,7 @@ export function registerMemojiRoutes(router: Router, serviceClient: SupabaseClie return; } - const { data, error } = await supabase.rpc("get_latest_memojis", { + const { data, error } = await userClient.rpc("get_latest_memojis", { p_limit: limit, p_offset: offset, }); diff --git a/supabase/migrations/20251220095459_add_get_latest_memojis_function.sql b/supabase/migrations/20251220095459_add_get_latest_memojis_function.sql deleted file mode 100644 index e25bdff..0000000 --- a/supabase/migrations/20251220095459_add_get_latest_memojis_function.sql +++ /dev/null @@ -1,26 +0,0 @@ -set check_function_bodies = off; - -CREATE OR REPLACE FUNCTION public.get_latest_memojis(p_limit integer, p_offset integer) - RETURNS TABLE(id uuid, name text, created_at timestamp with time zone, metadata jsonb) - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path TO 'public', 'storage', 'extensions' -AS $function$ -BEGIN - RETURN QUERY - SELECT - o.id, - o.name, - o.created_at, - o.metadata - FROM storage.objects o - WHERE o.bucket_id = 'memoji-images' - ORDER BY o.created_at DESC - LIMIT p_limit OFFSET p_offset; -END; -$function$ -; - -GRANT EXECUTE ON FUNCTION public.get_latest_memojis(integer, integer) TO anon, authenticated, service_role; - - diff --git a/supabase/migrations/20251222120000_fix_memoji_rpc.sql b/supabase/migrations/20251222120000_fix_memoji_rpc.sql new file mode 100644 index 0000000..3911f66 --- /dev/null +++ b/supabase/migrations/20251222120000_fix_memoji_rpc.sql @@ -0,0 +1,28 @@ + +CREATE OR REPLACE FUNCTION public.get_latest_memojis(p_limit int, p_offset int) +RETURNS TABLE ( + id uuid, + name text, + created_at timestamptz, + metadata jsonb +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, storage, extensions +AS $$ +BEGIN + RETURN QUERY + SELECT + o.id, + o.name, + o.created_at, + o.metadata + FROM storage.objects o + WHERE o.bucket_id = 'memoji-images' + AND o.owner = auth.uid() + ORDER BY o.created_at DESC + LIMIT p_limit OFFSET p_offset; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_latest_memojis(int, int) TO anon, authenticated, service_role; diff --git a/supabase/schemas/031_memoji_functions.sql b/supabase/schemas/031_memoji_functions.sql index 5528f37..e927bb0 100644 --- a/supabase/schemas/031_memoji_functions.sql +++ b/supabase/schemas/031_memoji_functions.sql @@ -38,6 +38,7 @@ BEGIN o.metadata FROM storage.objects o WHERE o.bucket_id = 'memoji-images' + AND o.owner = auth.uid() ORDER BY o.created_at DESC LIMIT p_limit OFFSET p_offset; END; diff --git a/supabase/tests/EndToEndTests/memoji.test.ts b/supabase/tests/EndToEndTests/memoji.test.ts index 0413f22..5369010 100644 --- a/supabase/tests/EndToEndTests/memoji.test.ts +++ b/supabase/tests/EndToEndTests/memoji.test.ts @@ -1,82 +1,89 @@ -import { functionsUrl, signInAnon, createSupabaseServiceClient } from "../_shared/utils.ts"; +import { functionsUrl, signInAnon, createSupabaseServiceClient, resolveSupabaseConfig } from "../_shared/utils.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3"; +import { assertEquals, assertExists, assert } from "https://deno.land/std@0.192.0/testing/asserts.ts"; -function assertEquals(actual: T, expected: T, message?: string): void { - if (!Object.is(actual, expected)) { - throw new Error( - message ?? `Assertion failed: expected ${expected}, received ${actual}`, - ); - } -} - -function assertExists(actual: unknown, message?: string): void { - if (actual === undefined || actual === null) { - throw new Error(message ?? "Expected value to exist"); - } -} +const BUCKET_NAME = "memoji-images"; -function assertArray(actual: unknown, message?: string): void { - if (!Array.isArray(actual)) { - throw new Error(message ?? `Expected value to be an array, but got ${typeof actual}`); - } +async function setupUser(baseUrl: string) { + const { accessToken } = await signInAnon(); + // Create a client scoped to this user ("authenticated" role) + // We explicitly use the ANON key but pass the User's Access Token in the Authorization header. + // This ensures that 'auth.uid()' in Postgres resolves to this user. + const { anonKey } = await resolveSupabaseConfig(); + const client = createClient(baseUrl, anonKey, { + global: { headers: { Authorization: `Bearer ${accessToken}` } }, + auth: { persistSession: false, autoRefreshToken: false } + }); + + return { client, accessToken }; } Deno.test({ - name: "memoji: get_latest_memojis", + name: "memoji: lifecycle (seed, fetch, pagination, cleanup)", sanitizeOps: false, sanitizeResources: false, - fn: async () => { - const { accessToken, baseUrl } = await signInAnon(); - - // Seed: Upload a dummy file if needed - const supabase = createSupabaseServiceClient({ baseUrl }); - - // Ensure bucket exists - await supabase.storage.createBucket("memoji-images", { public: true }); - - // Upload a test file - const testFileName = `test-${Date.now()}.txt`; - const { error: uploadError } = await supabase.storage - .from("memoji-images") - .upload(testFileName, new Blob(["dummy content"]), { upsert: true }); + fn: async (t) => { + // Shared setup + const { baseUrl } = await signInAnon(); + const serviceClient = createSupabaseServiceClient({ baseUrl }); + // Ensure bucket exists (using service role to be safe/idempotent) + await serviceClient.storage.createBucket(BUCKET_NAME, { public: true }); - if (uploadError) { - console.warn("Failed to seed dummy file (might already exist or permission issue):", uploadError); - } else { - console.log("Seeded dummy file:", testFileName); - } - - // Call the Edge Function - const response = await fetch(`${functionsUrl(baseUrl)}/ingredicheck/memojis/latest?limit=10&offset=0`, { - method: "GET", - headers: { - "Authorization": `Bearer ${accessToken}`, - "Content-Type": "application/json", - } - }); - - if (response.status !== 200) { - const text = await response.text(); - throw new Error(`RPC call failed with status ${response.status}: ${text}`); - } + // 1. Unauthorized Access Test + await t.step("should fail without auth (401)", async () => { + const res = await fetch(`${functionsUrl(baseUrl)}/ingredicheck/memojis/latest`, { + method: "GET" + }); + assertEquals(res.status, 401); + }); - const data = await response.json(); - - // Assertions - assertExists(data, "Response data should exist"); - assertArray(data.memojis, "Response.memojis should be an array"); - - // We might get an empty array if the bucket is empty, which is fine for this test. - // The main goal is to verify the function exists and is accessible. - console.log(`Fetched ${data.memojis.length} memojis.`); - - // If there are items, verify structure - if (data.memojis.length > 0) { - const first = data.memojis[0]; - assertExists(first.id, "Memoji ID should exist"); - assertExists(first.name, "Memoji name should exist"); - // meta check - // assertExists(first.metadata, "Memoji metadata should exist"); + // 2. Pagination & Security Test + await t.step("should return only user OWNED memojis and support pagination", async () => { + // Create User A + const userA = await setupUser(baseUrl); + + // Upload 3 files for User A with delays to ensure order + const filesA = ["fileA1.txt", "fileA2.txt", "fileA3.txt"]; + for (const f of filesA) { + const { error } = await userA.client.storage.from(BUCKET_NAME).upload(f, new Blob(["content"]), { upsert: true }); + if (error) throw error; + // robust time gap for ordering + await new Promise(r => setTimeout(r, 100)); + } + + // Verify User A sees 3 files + const listResponse = await fetch(`${functionsUrl(baseUrl)}/ingredicheck/memojis/latest?limit=10`, { + headers: { Authorization: `Bearer ${userA.accessToken}` } + }); + if (listResponse.status !== 200) { + console.error("Fetch failed:", listResponse.status, await listResponse.text()); + } + assertEquals(listResponse.status, 200); + const data = await listResponse.json(); + assertExists(data.memojis); + assertEquals(data.memojis.length, 3); + + // Check Pagination: Limit 2 + const page1Res = await fetch(`${functionsUrl(baseUrl)}/ingredicheck/memojis/latest?limit=2`, { + headers: { Authorization: `Bearer ${userA.accessToken}` } + }); + const page1 = await page1Res.json(); + assertEquals(page1.memojis.length, 2); + // Ordered by created_at DESC -> A3 (newest), A2 + assertEquals(page1.memojis[0].name, "fileA3.txt"); + assertEquals(page1.memojis[1].name, "fileA2.txt"); + + // Check Pagination: Offset 2 + const page2Res = await fetch(`${functionsUrl(baseUrl)}/ingredicheck/memojis/latest?limit=2&offset=2`, { + headers: { Authorization: `Bearer ${userA.accessToken}` } + }); + const page2 = await page2Res.json(); + assertEquals(page2.memojis.length, 1); + assertEquals(page2.memojis[0].name, "fileA1.txt"); + + // Cleanup User A files + await userA.client.storage.from(BUCKET_NAME).remove(filesA); + }); } - } });