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: 2 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ Pull requests run only tests tagged `@smoke` on desktop Chromium (`npm run test:

The server is never reused. CI uses retries and two workers. On failure, traces, screenshots, videos, and the HTML report stay in ignored `test-results/` and `playwright-report/` directories. CI may upload those credential-free artifacts for diagnosis.

Playwright starts that server with `VITE_E2E=1`. The client then keeps the environment switcher, but the production fallback is `http://e2e.invalid` instead of the production Supabase project, and a request that still names that project is refused in the browser. `playwright.config.ts` also refuses to start when `VITE_SUPABASE_ENV` or `SUPABASE_URL` already points at production. `E2E_ALLOW_PROD=1` is the explicit override. The production canary uses `playwright.production.config.ts` and does not set `VITE_E2E`. Plain `npm run dev` is unchanged and still defaults to production.

`e2e/hungarian-parity.spec.ts` is the focused Hungarian browser contract. On desktop and mobile Chromium it checks persisted `<html lang="hu">`, public and authenticated page chrome, route titles, onboarding, saved-chat empty state, Library controls, Timer semantics, localized close labels, and runtime errors. It deliberately does not reject arbitrary English text because source titles, user content, model names, and citations may legitimately remain in their source language.

`e2e/first-run.spec.ts` drives the same first run against stubbed Supabase routes in English and Hungarian, so it proves the client flow only.
Expand Down
2 changes: 1 addition & 1 deletion e2e/crossfire.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ async function installDeterministicBackend(page: Page, requests: CrossfireReques

const output = {
concessionsSecured: [{ turnIds: ['T2'], claim: 'The answer accepts that the safety link depends on traffic exposure.' }],
controlledQuestions: [{ turnIds: ['T1', 'T2'], claim: 'The opening exchange keeps the discussion on the central causal warrant.' }],
controlledQuestions: [{ turnIds: ['T1'], claim: 'The opening question keeps the discussion on the central causal warrant.' }],
weakOrRepetitiveQuestions: [{ turnIds: ['T1'], claim: 'The question could identify the type of evidence required more precisely.' }],
evasiveOrIncompleteAnswers: [{ turnIds: ['T2'], claim: 'The answer does not name a study or traffic count.' }],
missedFollowUps: [{ turnIds: ['T2'], claim: 'The next question should test the missing evidence.' }],
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/ai/schemas/source-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const SourceImportOutputSchema = z.object({
domain: z.string().min(1),
excerpt: z.string(),
url: SafeWebUrlSchema,
/** True when the page was longer than the import cap. */
truncated: z.boolean().optional(),
});

export type SourceImportOutput = z.infer<typeof SourceImportOutputSchema>;
16 changes: 11 additions & 5 deletions packages/core/src/crossfire/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,14 @@ export const CrossfireReplySchema = z.object({
}).strict();
export type CrossfireReply = z.infer<typeof CrossfireReplySchema>;

const TurnIdSchema = z.string().trim().min(1).max(40);
/** One cited turn. A later turn is not evidence about this one. */
const SingleTurnReferenceSchema = z.object({
turnIds: z.array(TurnIdSchema).length(1),
claim: boundedText(1, 800),
}).strict();
const TurnReferenceSchema = z.object({
turnIds: z.array(z.string().trim().min(1).max(40)).min(1).max(6),
turnIds: z.array(TurnIdSchema).min(1).max(6),
claim: boundedText(1, 800),
}).strict();

Expand All @@ -127,10 +133,10 @@ const BetterFollowUpSchema = z.object({
}).strict();

export const CrossfireDebriefSchema = z.object({
concessionsSecured: z.array(TurnReferenceSchema).max(MAX_CROSSFIRE_DEBRIEF_ITEMS),
controlledQuestions: z.array(TurnReferenceSchema).max(MAX_CROSSFIRE_DEBRIEF_ITEMS),
weakOrRepetitiveQuestions: z.array(TurnReferenceSchema).max(MAX_CROSSFIRE_DEBRIEF_ITEMS),
evasiveOrIncompleteAnswers: z.array(TurnReferenceSchema).max(MAX_CROSSFIRE_DEBRIEF_ITEMS),
concessionsSecured: z.array(SingleTurnReferenceSchema).max(MAX_CROSSFIRE_DEBRIEF_ITEMS),
controlledQuestions: z.array(SingleTurnReferenceSchema).max(MAX_CROSSFIRE_DEBRIEF_ITEMS),
weakOrRepetitiveQuestions: z.array(SingleTurnReferenceSchema).max(MAX_CROSSFIRE_DEBRIEF_ITEMS),
evasiveOrIncompleteAnswers: z.array(SingleTurnReferenceSchema).max(MAX_CROSSFIRE_DEBRIEF_ITEMS),
missedFollowUps: z.array(TurnReferenceSchema).max(MAX_CROSSFIRE_DEBRIEF_ITEMS),
betterFollowUpQuestions: z.array(BetterFollowUpSchema).max(MAX_CROSSFIRE_DEBRIEF_ITEMS),
strongestExchange: TurnReferenceSchema.nullable(),
Expand Down
7 changes: 7 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { defineConfig, devices } from '@playwright/test';
import { assertE2eSupabaseTarget } from './src/lib/supabase/e2e-guard';

assertE2eSupabaseTarget();

const PORT = Number(process.env.PORT || 18080);
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${PORT}`;
Expand Down Expand Up @@ -80,5 +83,9 @@ export default defineConfig({
timeout: 120_000,
stdout: 'ignore',
stderr: 'pipe',
env: {
VITE_E2E: '1',
...(process.env.E2E_ALLOW_PROD === '1' ? { VITE_E2E_ALLOW_PROD: '1' } : {}),
},
},
});
11 changes: 11 additions & 0 deletions src/lib/supabase/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
FunctionsHttpError,
FunctionsRelayError,
} from '@supabase/supabase-js';
import { e2eRefusesProductionRequest } from './e2e-guard';
import { getActiveEnv, SUPABASE_URL, SUPABASE_ANON_KEY } from './env';
import { verifySessionAfterRejection } from './session-expiry';

Expand Down Expand Up @@ -43,6 +44,16 @@ function requestUrl(input: RequestInfo | URL): string {
// holds its session lock while some of these requests run, so the check (which
// takes that lock) is queued for after the request instead of run inside it.
const watchedFetch: typeof fetch = async (input, init) => {
if (e2eRefusesProductionRequest({
allowProduction: import.meta.env.VITE_E2E_ALLOW_PROD === "1",
e2e: import.meta.env.VITE_E2E === "1",
url: requestUrl(input),
})) {
return new Response(JSON.stringify({ error: "e2e_refused_production" }), {
status: 403,
headers: { "content-type": "application/json" },
});
}
const response = await globalThis.fetch(input, init);
const url = requestUrl(input);
if (activeAccessToken && isAuthRejection(url, response.status)) {
Expand Down
39 changes: 39 additions & 0 deletions src/lib/supabase/e2e-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { SUPABASE_PROJECTS } from "./projects";

/** Where credential-free browser runs send the production fallback. Not a project. */
export const E2E_SUPABASE_URL = "http://e2e.invalid";
export const E2E_SUPABASE_ANON_KEY = "e2e-local-anon";

const PRODUCTION_ORIGIN = new URL(SUPABASE_PROJECTS.production.url).origin;

export function isProductionSupabaseUrl(url: string): boolean {
try {
return new URL(url).origin === PRODUCTION_ORIGIN;
} catch {
return false;
}
}

/**
* Credential-free e2e and agent browser runs must not call the production
* project. Set E2E_ALLOW_PROD=1 (and VITE_E2E_ALLOW_PROD=1 for the client) to
* force it, for example the production canary, which does not use this server.
*/
export function e2eRefusesProductionRequest(options: {
allowProduction: boolean;
e2e: boolean;
url: string;
}): boolean {
return options.e2e && !options.allowProduction && isProductionSupabaseUrl(options.url);
}

export function assertE2eSupabaseTarget(env: Record<string, string | undefined> = process.env): void {
if (env.E2E_ALLOW_PROD === "1") return;
const pinned = env.VITE_SUPABASE_ENV?.trim();
const configured = env.VITE_SUPABASE_URL?.trim() || env.SUPABASE_URL?.trim() || "";
if (pinned === "production" || (configured && isProductionSupabaseUrl(configured))) {
throw new Error(
"E2E refuses to target the production Supabase project. Set E2E_ALLOW_PROD=1 to force it.",
);
}
}
21 changes: 18 additions & 3 deletions src/lib/supabase/env.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
/** Runtime environment resolver for Supabase. */

import { E2E_SUPABASE_ANON_KEY, E2E_SUPABASE_URL } from "./e2e-guard";
import { STAGING_PREVIEW_PROXY_PATH, SUPABASE_PROJECTS } from "./projects";

export type SupabaseEnv = "local" | "production" | "staging";

type EnvironmentInput = {
VITE_E2E?: string;
VITE_E2E_ALLOW_PROD?: string;
VITE_LOCAL_PREVIEW?: string;
VITE_LOCAL_SUPABASE_ANON_KEY?: string;
VITE_LOCAL_SUPABASE_URL?: string;
Expand All @@ -27,6 +30,8 @@ export function resolveSupabaseConfig(
): SupabaseConfig {
const localPreview = environment.VITE_LOCAL_PREVIEW === "true";
const stagingPreview = environment.VITE_STAGING_PREVIEW === "true";
const e2e = environment.VITE_E2E === "1";
const allowProduction = environment.VITE_E2E_ALLOW_PROD === "1";
if (localPreview && stagingPreview) {
throw new Error("Local preview and staging preview cannot both be enabled");
}
Expand Down Expand Up @@ -62,12 +67,22 @@ export function resolveSupabaseConfig(
if (pinned !== "production" && pinned !== "staging") {
throw new Error(`Unknown VITE_SUPABASE_ENV: ${pinned}`);
}
if (e2e && pinned === "production" && !allowProduction) {
throw new Error("E2E refuses to target the production Supabase project. Set VITE_E2E_ALLOW_PROD=1 to force it.");
}
return { ...ENVS[pinned], env: pinned, pinned: true };
}

return storage.getItem(ENV_STORAGE_KEY) === "staging"
? { ...ENVS.staging, env: "staging" }
: { ...ENVS.production, env: "production" };
const selected = storage.getItem(ENV_STORAGE_KEY) === "staging"
? { ...ENVS.staging, env: "staging" as const }
: { ...ENVS.production, env: "production" as const };
// Playwright sets VITE_E2E. The saved toggle still selects staging, and the
// production fallback goes to a name that does not resolve, so a stub
// session cannot present a malformed token to the production project.
if (e2e && !allowProduction && selected.env === "production") {
return { anonKey: E2E_SUPABASE_ANON_KEY, env: "production", url: E2E_SUPABASE_URL };
}
return selected;
}

const storageReader: StorageReader = {
Expand Down
5 changes: 4 additions & 1 deletion src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,10 @@
"confirmBanDesc": "They can't use DebateLab until you unban them.",
"confirmUnban": "Unban {{email}}?",
"confirmRemoveAdmin": "Remove admin from {{email}}?",
"confirmRoleDesc": "They become a regular user."
"confirmRoleDesc": "They become a regular user.",
"loadMore": "Load more users",
"loadingMore": "Loading more users",
"loadMoreFailed": "Could not load more users"
},
"promos": {
"createTitle": "Create Promo Code",
Expand Down
5 changes: 4 additions & 1 deletion src/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,10 @@
"confirmBanDesc": "No podrá usar DebateLab hasta que lo desbloquees.",
"confirmUnban": "¿Desbloquear a {{email}}?",
"confirmRemoveAdmin": "¿Quitar el rol de administrador a {{email}}?",
"confirmRoleDesc": "Pasará a ser un usuario normal."
"confirmRoleDesc": "Pasará a ser un usuario normal.",
"loadMore": "Cargar más usuarios",
"loadingMore": "Cargando más usuarios",
"loadMoreFailed": "No se pudieron cargar más usuarios"
},
"promos": {
"createTitle": "Crear código promocional",
Expand Down
5 changes: 4 additions & 1 deletion src/locales/hu.json
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,10 @@
"confirmBanDesc": "Addig nem használhatja a DebateLabet, amíg fel nem oldod a tiltást.",
"confirmUnban": "Feloldod a tiltást: {{email}}?",
"confirmRemoveAdmin": "Elveszed az admin jogot: {{email}}?",
"confirmRoleDesc": "Átlagos felhasználó lesz."
"confirmRoleDesc": "Átlagos felhasználó lesz.",
"loadMore": "További felhasználók betöltése",
"loadingMore": "További felhasználók betöltése…",
"loadMoreFailed": "Nem sikerült további felhasználókat betölteni"
},
"promos": {
"createTitle": "Promóciós kód létrehozása",
Expand Down
42 changes: 40 additions & 2 deletions src/pages/AdminPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,9 @@ export default function AdminPage() {
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<UsageStats | null>(null);
const [users, setUsers] = useState<User[]>([]);
const [usersPage, setUsersPage] = useState(1);
const [usersHasMore, setUsersHasMore] = useState(false);
const [loadingMoreUsers, setLoadingMoreUsers] = useState(false);
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
const [promoCodes, setPromoCodes] = useState<PromoCode[]>([]);
const [subscribers, setSubscribers] = useState<Subscriber[]>([]);
Expand All @@ -467,12 +470,13 @@ export default function AdminPage() {
return '$0.0000';
};

const fetchAdminData = useCallback(async (action: string) => {
const fetchAdminData = useCallback(async (action: string, params?: Record<string, string>) => {
const { data: { session } } = await supabase.auth.getSession();
if (!session) throw new Error(t('admin.errors.notAuthenticated'));

const query = new URLSearchParams({ action, ...params });
const response = await fetch(
`${SUPABASE_URL}/functions/v1/admin?action=${action}`,
`${SUPABASE_URL}/functions/v1/admin?${query}`,
{
headers: {
'Authorization': `Bearer ${session.access_token}`,
Expand Down Expand Up @@ -572,6 +576,8 @@ export default function AdminPage() {

setStats(enrichedStats);
setUsers(loadedUsers);
setUsersPage(typeof usersData.page === 'number' ? usersData.page : 1);
setUsersHasMore(Boolean(usersData.hasMore));
setPromoCodes(promoData.codes || []);
setSubscribers(subData.subscribers || []);
setDemoCodes(getDemoCodes(demoData.data));
Expand Down Expand Up @@ -601,6 +607,30 @@ export default function AdminPage() {
if (isAdmin === true) captureProductEvent('tool_opened', { tool: 'admin' });
}, [isAdmin]);

const loadMoreUsers = async () => {
if (loadingMoreUsers) return;
setLoadingMoreUsers(true);
try {
const nextPage = usersPage + 1;
const data = await fetchAdminData('users', { page: String(nextPage), perPage: '50' });
const incoming = (data.users || []) as User[];
setUsers((current) => {
const seen = new Set(current.map((listed) => listed.id));
return [...current, ...incoming.filter((listed) => !seen.has(listed.id))];
});
setUsersPage(typeof data.page === 'number' ? data.page : nextPage);
setUsersHasMore(Boolean(data.hasMore));
} catch (err) {
toast({
title: t('admin.error'),
description: err instanceof Error ? err.message : t('admin.users.loadMoreFailed'),
variant: 'destructive',
});
} finally {
setLoadingMoreUsers(false);
}
};

/** Resolves true once the role is saved; failures are reported here and leave the caller's dialog open. */
const handleSetUserRole = async (userId: string, role: string) => {
try {
Expand Down Expand Up @@ -1032,6 +1062,14 @@ export default function AdminPage() {
))}
</div>
</ScrollArea>
{usersHasMore && (
<div className="mt-4 flex justify-center">
<Button variant="outline" onClick={() => void loadMoreUsers()} disabled={loadingMoreUsers}>
{loadingMoreUsers && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{loadingMoreUsers ? t('admin.users.loadingMore') : t('admin.users.loadMore')}
</Button>
</div>
)}
</CardContent>
</Card>
</TabsContent>
Expand Down
38 changes: 38 additions & 0 deletions src/test/supabase/pinned-env.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import { E2E_SUPABASE_URL, assertE2eSupabaseTarget, e2eRefusesProductionRequest } from "@/lib/supabase/e2e-guard";
import { resolveSupabaseConfig } from "@/lib/supabase/env";
import { SUPABASE_PROJECTS } from "@/lib/supabase/projects";

const noToggle = { getItem: (): string | null => null };
const stagingToggle = { getItem: () => "staging" };
Expand Down Expand Up @@ -28,4 +30,40 @@ describe("build-pinned Supabase project", () => {
expect(resolveSupabaseConfig({}, stagingToggle)).toMatchObject({ env: "staging" });
expect(resolveSupabaseConfig({ VITE_SUPABASE_ENV: " " }, noToggle)).toMatchObject({ env: "production" });
});

it("sends credential-free e2e away from the production project", () => {
expect(resolveSupabaseConfig({ VITE_E2E: "1" }, noToggle)).toMatchObject({
env: "production",
url: E2E_SUPABASE_URL,
});
expect(resolveSupabaseConfig({ VITE_E2E: "1" }, stagingToggle)).toMatchObject({
env: "staging",
url: SUPABASE_PROJECTS.staging.url,
});
expect(resolveSupabaseConfig({ VITE_E2E: "1", VITE_E2E_ALLOW_PROD: "1" }, noToggle)).toMatchObject({
env: "production",
url: SUPABASE_PROJECTS.production.url,
});
expect(() => resolveSupabaseConfig({ VITE_E2E: "1", VITE_SUPABASE_ENV: "production" }, noToggle))
.toThrow("production Supabase project");
});
});

describe("e2e production guard", () => {
const production = SUPABASE_PROJECTS.production.url;

it("refuses a production URL unless the run is forced", () => {
expect(e2eRefusesProductionRequest({ e2e: true, allowProduction: false, url: `${production}/auth/v1/user` })).toBe(true);
expect(e2eRefusesProductionRequest({ e2e: true, allowProduction: true, url: `${production}/auth/v1/user` })).toBe(false);
expect(e2eRefusesProductionRequest({ e2e: false, allowProduction: false, url: `${production}/auth/v1/user` })).toBe(false);
expect(e2eRefusesProductionRequest({ e2e: true, allowProduction: false, url: "http://e2e.invalid/auth/v1/user" })).toBe(false);
expect(e2eRefusesProductionRequest({ e2e: true, allowProduction: false, url: SUPABASE_PROJECTS.staging.url })).toBe(false);
});

it("refuses to start when the process is pointed at production", () => {
expect(() => assertE2eSupabaseTarget({ VITE_SUPABASE_ENV: "production" })).toThrow("production Supabase project");
expect(() => assertE2eSupabaseTarget({ SUPABASE_URL: production })).toThrow("production Supabase project");
expect(() => assertE2eSupabaseTarget({ VITE_SUPABASE_ENV: "production", E2E_ALLOW_PROD: "1" })).not.toThrow();
expect(() => assertE2eSupabaseTarget({})).not.toThrow();
});
});
2 changes: 2 additions & 0 deletions src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
interface ImportMetaEnv {
readonly VITE_LOCAL_PREVIEW?: string;
readonly VITE_LOCAL_SUPABASE_ANON_KEY?: string;
readonly VITE_E2E?: string;
readonly VITE_E2E_ALLOW_PROD?: string;
readonly VITE_LOCAL_SUPABASE_URL?: string;
readonly VITE_STAGING_PREVIEW?: string;
readonly VITE_COLLABORATION_URL?: string;
Expand Down
2 changes: 1 addition & 1 deletion supabase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ Supabase injects `SUPABASE_URL`, `SUPABASE_ANON_KEY` and `SUPABASE_SERVICE_ROLE_
- `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`, or checkout, the billing portal, the webhook and account deletion fail (`stripe-webhook` fails at startup without them);
- `ELEVENLABS_API_KEY` for the opponent's voice, which otherwise falls back to the browser voice.

`DEMO_CODE_PEPPER` and `SPARRING_INTEGRITY_SECRET` fall back to a value derived from the service-role key. The `WORKSPACE_INVITE_*` values serve only the hidden Workspace. `npm run backend:status -- --project-ref <ref>` lists which expected names a project lacks, without printing values.
`DEMO_CODE_PEPPER` and `SPARRING_INTEGRITY_SECRET` fall back to a value derived from the service-role key. Demo-code login logs a warning when `DEMO_CODE_PEPPER` is missing and still accepts codes hashed with that fallback. The `WORKSPACE_INVITE_*` values serve only the hidden Workspace. `npm run backend:status -- --project-ref <ref>` lists which expected names a project lacks, without printing values.

Use `supabase secrets` or protected deployment configuration for server values. Never place server secrets in `VITE_*`, source files, docs, screenshots, traces, logs, or comments.

Expand Down
Loading
Loading