diff --git a/deno.lock b/deno.lock index 18a982e..db90b1a 100644 --- a/deno.lock +++ b/deno.lock @@ -15,9 +15,21 @@ } }, "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", "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", @@ -26,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", @@ -110,19 +126,46 @@ "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", "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..987da61 100644 --- a/supabase/functions/ingredicheck/memoji.ts +++ b/supabase/functions/ingredicheck/memoji.ts @@ -359,6 +359,65 @@ 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 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) { + ctx.response.status = 401; + ctx.response.body = { error: { code: "AUTH_REQUIRED", message: "Sign in required." } }; + 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"); + + 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 userClient.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/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 293f7e4..e927bb0 100644 --- a/supabase/schemas/031_memoji_functions.sql +++ b/supabase/schemas/031_memoji_functions.sql @@ -17,4 +17,33 @@ 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' + 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/tests/EndToEndTests/memoji.test.ts b/supabase/tests/EndToEndTests/memoji.test.ts new file mode 100644 index 0000000..5369010 --- /dev/null +++ b/supabase/tests/EndToEndTests/memoji.test.ts @@ -0,0 +1,89 @@ + +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"; + +const BUCKET_NAME = "memoji-images"; + +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: lifecycle (seed, fetch, pagination, cleanup)", + sanitizeOps: false, + sanitizeResources: false, + 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 }); + + // 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); + }); + + // 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); + }); + } +});