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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"test:collaboration": "vitest run --config services/collaboration/vitest.config.ts",
"start:collaboration": "tsx services/collaboration/src/index.ts",
"test:watch": "vitest",
"test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/",
"test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,STRIPE_SECRET_KEY,STRIPE_PRICE_ID,STRIPE_PRICE_LOOKUP_KEY,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/",
"test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-workspace-invite-concurrency.sh && bash scripts/test-workspace-mutation-concurrency.sh",
"test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium",
"test:e2e:webkit": "playwright test --project=webkit",
Expand Down
5 changes: 4 additions & 1 deletion src/test/security-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ describe('security hardening regressions', () => {
it('uses code-scoped sharing and disallows anonymous share creation', () => {
expect(migration).toContain('DROP POLICY IF EXISTS "shared_items_select_public"');
expect(migration).toContain('FUNCTION public.get_shared_item(p_share_code text)');
expect(share).toContain('if (!userId)');
// requireUser refuses a missing, empty, or rejected token with 401, so the
// creator is always a verified user. share/auth_test.ts covers the behavior.
expect(share).toContain('userId = (await requireUser(req)).user.id');
expect(share).toContain('created_by: userId');
expect(share).toContain('crypto.getRandomValues');
const sharedPage = read('src/pages/SharedPage.tsx');
expect(sharedPage).toContain("rpc('get_shared_item'");
Expand Down
91 changes: 91 additions & 0 deletions supabase/functions/_shared/testing/bearer-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Test support for Edge Functions that authenticate with requireUser.
// Deno only collects *_test.ts and *.test.ts files, so this module is not a
// test itself, and no deployed function imports it.
import { assertEquals } from "jsr:@std/assert@1.0.19";

export const TEST_SUPABASE_URL = "https://supabase.test";
export const TEST_ANON_KEY = "test-anon-key";
export const VALID_TOKEN = "valid-token";
export const TEST_USER = {
id: "11111111-1111-4111-8111-111111111111",
aud: "authenticated",
role: "authenticated",
email: "user@example.test",
};

export type BearerCase = {
label: string;
authorization?: string;
status: number;
body: unknown;
/** Calls to the Auth user endpoint this request may make. */
authCalls: number;
};

/** Missing, empty, non-Bearer and rejected credentials, all refused with 401. */
export function rejectedBearerCases(bodies: { required: unknown; invalid: unknown }): BearerCase[] {
return [
{ label: "missing header", status: 401, body: bodies.required, authCalls: 0 },
{ label: "empty bearer token", authorization: "Bearer ", status: 401, body: bodies.required, authCalls: 0 },
{ label: "non-Bearer scheme", authorization: "Basic dXNlcjpwYXNz", status: 401, body: bodies.required, authCalls: 0 },
{ label: "rejected token", authorization: "Bearer expired-token", status: 401, body: bodies.invalid, authCalls: 1 },
];
}

type OtherFetch = (url: URL, headers: Headers, init?: RequestInit) => Response | Promise<Response>;

function headersOf(input: Request | URL | string, init?: RequestInit): Headers {
const headers = new Headers(input instanceof Request ? input.headers : undefined);
new Headers(init?.headers).forEach((value, key) => headers.set(key, value));
return headers;
}

/**
* Sends one request per case through `handle`. Supabase Auth accepts only
* VALID_TOKEN; any other outbound request goes to `otherFetch`, or fails the
* test when none is given.
*/
export async function runBearerCases(
handle: (req: Request) => Promise<Response>,
makeRequest: (authorization?: string) => Request,
cases: BearerCase[],
otherFetch?: OtherFetch,
): Promise<void> {
const originalFetch = globalThis.fetch;
const authCalls: string[] = [];

globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => {
const url = new URL(input instanceof Request ? input.url : String(input));
const headers = headersOf(input, init);
if (url.origin === TEST_SUPABASE_URL && url.pathname === "/auth/v1/user") {
const authorization = headers.get("authorization") ?? "";
authCalls.push(authorization);
return authorization === `Bearer ${VALID_TOKEN}`
? Response.json(TEST_USER)
: Response.json({ code: 401, error_code: "bad_jwt", msg: "invalid JWT" }, { status: 401 });
}
if (otherFetch) return await otherFetch(url, headers, init);
throw new Error(`Unexpected request: ${url}`);
}) as typeof fetch;

try {
for (const testCase of cases) {
authCalls.length = 0;
const response = await handle(makeRequest(testCase.authorization));
const text = await response.text();
let body: unknown = text;
try {
body = JSON.parse(text);
} catch {
// Keep the raw text so the assertion shows it.
}
assertEquals(
{ status: response.status, body, authCalls: authCalls.length },
{ status: testCase.status, body: testCase.body, authCalls: testCase.authCalls },
testCase.label,
);
}
} finally {
globalThis.fetch = originalFetch;
}
}
42 changes: 42 additions & 0 deletions supabase/functions/admin/auth_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {
rejectedBearerCases,
runBearerCases,
TEST_ANON_KEY,
TEST_SUPABASE_URL,
TEST_USER,
VALID_TOKEN,
} from "../_shared/testing/bearer-auth.ts";

for (const [key, value] of Object.entries({
SUPABASE_URL: TEST_SUPABASE_URL,
SUPABASE_ANON_KEY: TEST_ANON_KEY,
SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key",
})) Deno.env.set(key, value);

const { handleRequest } = await import("./index.ts");

const send = (authorization?: string) =>
new Request("http://127.0.0.1/functions/v1/admin?action=announcements", {
method: "GET",
headers: authorization === undefined ? {} : { Authorization: authorization },
});

Deno.test("admin refuses missing, empty, non-Bearer and rejected tokens with the shared auth errors", async () => {
await runBearerCases(handleRequest, send, rejectedBearerCases({ required: { error: "Authentication required" }, invalid: { error: "Invalid authentication token" } }));
});

Deno.test("admin checks the role of the user the token belongs to", async () => {
const roleQueries: string[] = [];
await runBearerCases(handleRequest, send, [{
label: "valid token, ordinary user",
authorization: `Bearer ${VALID_TOKEN}`,
status: 403,
body: { error: "Admin access required" },
authCalls: 1,
}], (url) => {
if (url.pathname !== "/rest/v1/user_roles") throw new Error(`Unexpected request: ${url}`);
roleQueries.push(url.searchParams.get("user_id") ?? "");
return Response.json({ role: "user" });
});
if (roleQueries.join() !== `eq.${TEST_USER.id}`) throw new Error(`role lookups: ${roleQueries.join()}`);
});
38 changes: 15 additions & 23 deletions supabase/functions/admin/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
// Supabase Edge Function: Admin API for DebateLab
// Provides usage statistics, rate limit management, and user management

import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { createClient, type User } from "https://esm.sh/@supabase/supabase-js@2";
import {
validateAnnouncementId,
validateCreateAnnouncement,
} from "../_shared/announcements.ts";
import { generateDemoCode, hashDemoCode, isCreatableDemoRole } from "../_shared/demo-code.ts";
import { collectAdminUsagePages, getKnownCost, hasUnknownCost, isNonAiActivity } from "../_shared/admin-cost.ts";
import { HttpError, jsonError, requireUser } from "../_shared/auth.ts";
import { getCorsHeaders } from "../_shared/cors.ts";

// SECURITY: Allow specific origins for CORS
Expand Down Expand Up @@ -86,7 +87,7 @@ async function assertCurrentAdmin(userId: string) {
}
}

Deno.serve(async (req) => {
export async function handleRequest(req: Request): Promise<Response> {
const origin = req.headers.get("origin");
const corsHeaders = getCorsHeaders(origin, { methods: "POST, GET, OPTIONS" });

Expand All @@ -95,26 +96,13 @@ Deno.serve(async (req) => {
}

// Authenticate request
const authHeader = req.headers.get("authorization");
console.log("[ADMIN] Auth header present:", !!authHeader, "starts with Bearer:", authHeader?.startsWith("Bearer "));
if (!authHeader?.startsWith("Bearer ")) {
console.log("[ADMIN] No auth header, returning 401");
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}

const token = authHeader.slice(7);
console.log("[ADMIN] Token length:", token.length);
const { data: { user }, error: authError } = await supabase.auth.getUser(token);
console.log("[ADMIN] getUser result - user:", user?.id, "error:", authError?.message);
if (authError || !user) {
console.log("[ADMIN] Auth failed, returning 401");
return new Response(JSON.stringify({ error: "Invalid token" }), {
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
let user: User;
try {
user = (await requireUser(req)).user;
} catch (error) {
if (!(error instanceof HttpError)) throw error;
console.warn("[ADMIN] Authentication rejected:", error.message);
return jsonError(error, corsHeaders);
}

// Check if user is admin
Expand Down Expand Up @@ -1032,4 +1020,8 @@ Deno.serve(async (req) => {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});
}

if (import.meta.main) {
Deno.serve(handleRequest);
}
29 changes: 29 additions & 0 deletions supabase/functions/ai-stream/auth_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
rejectedBearerCases,
runBearerCases,
TEST_ANON_KEY,
TEST_SUPABASE_URL,
} from "../_shared/testing/bearer-auth.ts";

for (const [key, value] of Object.entries({
SUPABASE_URL: TEST_SUPABASE_URL,
SUPABASE_ANON_KEY: TEST_ANON_KEY,
SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key",
OPENROUTER_API_KEY: "test-openrouter-key",
})) Deno.env.set(key, value);

const { handleRequest } = await import("./index.ts");

const send = (authorization?: string) =>
new Request("http://127.0.0.1/functions/v1/ai-stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(authorization === undefined ? {} : { Authorization: authorization }),
},
body: JSON.stringify({ feature: "ask", model: "openai/gpt-5.6-luna", input: { message: "Question", mode: "general" } }),
});

Deno.test("ai-stream refuses missing, empty, non-Bearer and rejected tokens with the shared auth errors", async () => {
await runBearerCases(handleRequest, send, rejectedBearerCases({ required: { error: "Authentication required" }, invalid: { error: "Invalid authentication token" } }));
});
1 change: 1 addition & 0 deletions supabase/functions/ai-stream/handler_test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const testEnvironment = {
SUPABASE_URL: "https://supabase.test",
SUPABASE_ANON_KEY: "test-anon-key",
SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key",
OPENROUTER_API_KEY: "test-openrouter-key",
};
Expand Down
19 changes: 9 additions & 10 deletions supabase/functions/ai-stream/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Supabase Edge Function: streaming Ask proxy for DebateLab.
// Keeps provider keys server-side and uses SSE for progressive UI rendering.

import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { createClient, type User } from "https://esm.sh/@supabase/supabase-js@2";
import { getAskModeGuidance } from "../ai/prompt-builder.ts";
import { isAskStreamingModeAllowed, validateControlInput } from "../_shared/control-enforcement.ts";
import { boundPromptSources, buildStreamingAskSystemPrompt, type StreamingAskMaterial } from "../_shared/prompts.ts";
Expand All @@ -18,6 +18,7 @@ import {
normalizeOpenRouterUsage,
type NormalizedUsage,
} from "../_shared/cost.ts";
import { HttpError, jsonError, requireUser } from "../_shared/auth.ts";
import { getCorsHeaders } from "../_shared/cors.ts";
import { checkRateLimit, logUsage } from "../_shared/entitlements.ts";
import { OPENROUTER_API_URL } from "../_shared/research.ts";
Expand Down Expand Up @@ -101,15 +102,13 @@ export async function handleRequest(req: Request): Promise<Response> {
return jsonResponse({ error: "Method not allowed" }, 405, corsHeaders);
}

const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return jsonResponse({ error: "Authentication required" }, 401, corsHeaders);
}

const token = authHeader.replace("Bearer ", "");
const { data: { user }, error: authError } = await supabase.auth.getUser(token);
if (authError || !user) {
return jsonResponse({ error: "Invalid authentication token" }, 401, corsHeaders);
let user: User;
try {
user = (await requireUser(req)).user;
} catch (error) {
if (!(error instanceof HttpError)) throw error;
console.warn(JSON.stringify({ function: "ai-stream", event: "auth_rejected", error: error.message }));
return jsonError(error, corsHeaders);
}

let body: Record<string, unknown>;
Expand Down
1 change: 1 addition & 0 deletions supabase/functions/ai-stream/index_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ function jsonResponse(body: unknown, status = 200): Response {

for (const [key, value] of Object.entries({
SUPABASE_URL: "https://supabase.test",
SUPABASE_ANON_KEY: "test-anon-key",
SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key",
OPENROUTER_API_KEY: "test-openrouter-key",
})) Deno.env.set(key, value);
Expand Down
40 changes: 40 additions & 0 deletions supabase/functions/create-checkout/auth_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {
rejectedBearerCases,
runBearerCases,
TEST_ANON_KEY,
TEST_SUPABASE_URL,
VALID_TOKEN,
} from "../_shared/testing/bearer-auth.ts";

for (const [key, value] of Object.entries({
SUPABASE_URL: TEST_SUPABASE_URL,
SUPABASE_ANON_KEY: TEST_ANON_KEY,
SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key",
STRIPE_SECRET_KEY: "sk_test_not_used",
})) Deno.env.set(key, value);

const { handleRequest } = await import("./index.ts");

const send = (authorization?: string) =>
new Request("http://127.0.0.1/functions/v1/create-checkout", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(authorization === undefined ? {} : { Authorization: authorization }),
},
body: JSON.stringify({}),
});

Deno.test("create-checkout refuses missing, empty, non-Bearer and rejected tokens with the shared auth errors", async () => {
await runBearerCases(handleRequest, send, rejectedBearerCases({ required: { error: "Authentication required" }, invalid: { error: "Invalid authentication token" } }));
});

Deno.test("create-checkout lets a valid token through to request validation", async () => {
await runBearerCases(handleRequest, send, [{
label: "valid token",
authorization: `Bearer ${VALID_TOKEN}`,
status: 400,
body: { error: "Missing required fields: successUrl, cancelUrl" },
authCalls: 1,
}]);
});
Loading
Loading