Skip to content
Open
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
43 changes: 43 additions & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

59 changes: 59 additions & 0 deletions supabase/functions/ingredicheck/memoji.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions supabase/migrations/20251222120000_fix_memoji_rpc.sql
Original file line number Diff line number Diff line change
@@ -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;
29 changes: 29 additions & 0 deletions supabase/schemas/031_memoji_functions.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;


89 changes: 89 additions & 0 deletions supabase/tests/EndToEndTests/memoji.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@

import { functionsUrl, signInAnon, createSupabaseServiceClient, resolveSupabaseConfig } from "../_shared/utils.ts";
import { createClient } from "https://esm.sh/@supabase/[email protected]";
import { assertEquals, assertExists, assert } from "https://deno.land/[email protected]/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);
});
}
});