Skip to content
Draft
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
343 changes: 343 additions & 0 deletions __tests__/view-as-proxy-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,343 @@
/**
* CENTRAL View-As read-only guard — proxy.ts choke-point tests.
*
* The per-route blockMutationIfViewing() (covered in view-as-wiring.test.ts) only
* protects the handful of routes that remember to call it. This suite proves the
* CENTRAL guard in proxy.ts — the one layer every /api request crosses — refuses
* EVERY non-GET request when a valid founder View-As scope is active, across
* representative route groups (settings, integrations, billing, team, generic),
* NOT just the 3 hand-wired routes. It also proves reads pass, non-founders /
* expired / tampered cookies are ignored, and the whole thing is a no-op when
* VIEW_AS_ENABLED is off.
*
* Harness mirrors __tests__/middleware.test.ts (mocked next/server + @supabase/ssr
* + rate-limit) but is self-contained so it can drive the View-As cookie + the
* audit-insert path without disturbing the main middleware assertions.
*
* Crypto note: SECRET defaults to "view-as-dev-insecure-secret" (no
* VIEW_AS_SECRET / SUPABASE_SERVICE_ROLE_KEY here), and packScope() signs with
* the same default, so the signed cookies verify.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { packScope, VIEW_AS_COOKIE, type ViewScope } from "@/lib/auth/view-as-token";

// ── Mocks ────────────────────────────────────────────────────────────

const mockCheckRateLimit = vi.fn();
const mockGetClientIP = vi.fn().mockReturnValue("127.0.0.1");
const mockRateLimitResponse = vi.fn();
const mockGetRateLimitMode = vi.fn().mockReturnValue("distributed");
const mockIsRateLimitDegraded = vi.fn().mockReturnValue(false);

vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: (...args: unknown[]) => mockCheckRateLimit(...args),
getClientIP: (...args: unknown[]) => mockGetClientIP(...args),
getRateLimitMode: (...args: unknown[]) => mockGetRateLimitMode(...args),
isRateLimitDegraded: (...args: unknown[]) => mockIsRateLimitDegraded(...args),
rateLimitResponse: (...args: unknown[]) => mockRateLimitResponse(...args),
}));

// Deterministic founder gate: founder@relaylaunch.com is the only founder.
vi.mock("@/lib/constants", () => ({
isFounderEmail: (email: string | null | undefined) =>
(email ?? "").toLowerCase() === "founder@relaylaunch.com",
FOUNDER_EMAILS: ["founder@relaylaunch.com"],
}));

// Supabase mock — auth.getUser + from().select()...maybeSingle() for tier/profile
// lookups AND from("audit_logs").insert() for the central guard's audit write.
const mockGetUser = vi.fn();
const mockMaybeSingle = vi.fn();
const mockAuditInsert = vi.fn().mockResolvedValue({ data: null, error: null });

vi.mock("@supabase/ssr", () => ({
createServerClient: vi.fn(() => ({
auth: { getUser: mockGetUser },
from: (_table: string) => ({
select: () => ({
eq: () => ({
maybeSingle: mockMaybeSingle,
single: mockMaybeSingle,
}),
}),
insert: (...args: unknown[]) => mockAuditInsert(...args),
}),
})),
}));

// Next.js mock — track responses (status/headers/body/type).
class MockHeaders {
private store = new Map<string, string>();
set(key: string, value: string) { this.store.set(key, value); }
get(key: string) { return this.store.get(key) ?? null; }
has(key: string) { return this.store.has(key); }
}

class MockCookies {
private store = new Map<string, { name: string; value: string }>();
set(name: string, value: string) { this.store.set(name, { name, value }); }
get(name: string) { return this.store.get(name); }
getAll() { return [...this.store.values()]; }
}

interface MockResponse {
status: number;
headers: MockHeaders;
cookies: MockCookies;
body?: unknown;
redirectUrl?: string;
_type: "next" | "redirect" | "json" | "raw";
}

function createMockResponse(status: number, type: MockResponse["_type"], extra: Partial<MockResponse> = {}): MockResponse {
return { status, headers: new MockHeaders(), cookies: new MockCookies(), _type: type, ...extra };
}

vi.mock("next/server", () => {
class MockNextResponse {
status: number;
headers: MockHeaders;
cookies: MockCookies;
body: string;
_type: string;
constructor(body: string, init?: { status?: number }) {
this.status = init?.status || 200;
this.headers = new MockHeaders();
this.cookies = new MockCookies();
this.body = body;
this._type = "raw";
}
static next() { return createMockResponse(200, "next"); }
static redirect(url: URL | { toString(): string }) {
return createMockResponse(307, "redirect", { redirectUrl: url.toString() });
}
static json(data: unknown, init?: { status?: number }) {
return createMockResponse(init?.status || 200, "json", { body: data });
}
}
return { NextResponse: MockNextResponse };
});

// Deterministic nonce.
vi.stubGlobal("crypto", {
getRandomValues: (arr: Uint8Array) => { arr.fill(42); return arr; },
});

// ── Helpers ──────────────────────────────────────────────────────────

function createCloneableURL(pathname: string) {
const url = new URL(`https://deck.relaylaunch.com${pathname}`);
(url as URL & { clone: () => URL }).clone = () => new URL(url.toString());
return url as URL & { clone: () => URL };
}

function createRequest(pathname: string, options: {
method?: string;
origin?: string;
cookies?: Record<string, string>;
} = {}) {
const url = createCloneableURL(pathname);
const reqHeaders = new MockHeaders();
// Default to the allowed origin so the CSRF check passes and we reach the guard.
reqHeaders.set("origin", options.origin ?? "https://deck.relaylaunch.com");

const reqCookies = new MockCookies();
if (options.cookies) {
for (const [k, v] of Object.entries(options.cookies)) reqCookies.set(k, v);
}

return {
nextUrl: url,
url: url.toString(),
method: options.method || "GET",
headers: reqHeaders,
cookies: reqCookies,
};
}

const FOUNDER = { id: "user_founder", email: "founder@relaylaunch.com" };
const CLIENT = { id: "user_client", email: "client@example.com" };

function validScope(overrides: Partial<ViewScope> = {}): ViewScope {
return {
tenantId: "tenant_viewed",
label: "Demo Spa",
mode: "client_admin",
sessionId: "sess_test",
exp: Date.now() + 60_000,
...overrides,
};
}

/** Signed, valid-by-default View-As cookie value. */
function scopeCookie(scope: ViewScope = validScope()): string {
return packScope(scope);
}

// Representative mutation routes spanning DIFFERENT route groups — explicitly
// NOT the 3 hand-wired ones (approvals / focus/approve / outreach/flush). The
// point is the CENTRAL guard covers everything, including these.
const REPRESENTATIVE_MUTATION_ROUTES = [
"/api/v1/settings/profile",
"/api/v1/integrations/google/connect",
"/api/v1/billing/checkout",
"/api/v1/team/invitations",
"/api/v1/keys",
"/api/v1/documents",
"/api/v1/some-brand-new-route-nobody-guarded",
];

// ── Import middleware AFTER mocks ────────────────────────────────────
let middleware: (request: ReturnType<typeof createRequest>) => Promise<MockResponse>;

beforeEach(async () => {
process.env.NEXT_PUBLIC_SUPABASE_URL = "https://test.supabase.co";
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = "test-anon-key";
process.env.ALLOWED_ORIGINS = "https://deck.relaylaunch.com";
vi.stubEnv("NODE_ENV", "production");
// Feature flag ON by default for this suite; individual cases flip it OFF.
vi.stubEnv("VIEW_AS_ENABLED", "true");
vi.stubEnv("NEXT_PUBLIC_VIEW_AS_ENABLED", "");

mockCheckRateLimit.mockResolvedValue({ allowed: true, remaining: 99, retryAfterMs: 0 });
mockGetRateLimitMode.mockReturnValue("distributed");
mockIsRateLimitDegraded.mockReturnValue(false);
mockRateLimitResponse.mockReturnValue(createMockResponse(429, "json", { body: { error: "Rate limited" } }));

// Default authenticated principal = the FOUNDER (so the guard can fire).
mockGetUser.mockResolvedValue({ data: { user: FOUNDER } });
mockMaybeSingle.mockResolvedValue({ data: { tenant_id: "tenant_own", plan_tier: "Founder", role: "admin" } });
mockAuditInsert.mockClear();
mockAuditInsert.mockResolvedValue({ data: null, error: null });

const mod = await import("../proxy");
middleware = mod.proxy as unknown as typeof middleware;
});

afterEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
});

// =====================================================================
// 1. Central guard BLOCKS mutations across representative route groups
// =====================================================================

describe("central View-As guard — blocks ALL mutation routes (not just the 3)", () => {
for (const route of REPRESENTATIVE_MUTATION_ROUTES) {
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
it(`blocks ${method} ${route} with 403 VIEW_AS_READ_ONLY under an active founder scope`, async () => {
const res = await middleware(createRequest(route, {
method,
cookies: { [VIEW_AS_COOKIE]: scopeCookie() },
}));
expect(res.status).toBe(403);
expect((res.body as { code?: string })?.code).toBe("VIEW_AS_READ_ONLY");
expect((res.body as { viewed_tenant_id?: string })?.viewed_tenant_id).toBe("tenant_viewed");
// Security headers still applied to the 403.
expect(res.headers.get("Content-Security-Policy")).toBeTruthy();
});
}
}

it("writes a view_as_blocked_mutation audit row (method + path) on a blocked write", async () => {
await middleware(createRequest("/api/v1/settings/profile", {
method: "POST",
cookies: { [VIEW_AS_COOKIE]: scopeCookie() },
}));
expect(mockAuditInsert).toHaveBeenCalledTimes(1);
const row = mockAuditInsert.mock.calls[0][0] as {
action: string; status_code: number; details: Record<string, unknown>;
};
expect(row.action).toBe("view_as_blocked_mutation");
expect(row.status_code).toBe(403);
expect(row.details.viewed_tenant_id).toBe("tenant_viewed");
expect(row.details.method).toBe("POST");
expect(row.details.path).toBe("/api/v1/settings/profile");
expect(row.details.enforced_by).toBe("proxy_central_guard");
});
});

// =====================================================================
// 2. Reads still pass under an active scope (lens, not lockout)
// =====================================================================

describe("central View-As guard — reads pass", () => {
it("ALLOWS GET under an active founder scope (200, no audit, no block)", async () => {
const res = await middleware(createRequest("/api/v1/settings/profile", {
method: "GET",
cookies: { [VIEW_AS_COOKIE]: scopeCookie() },
}));
expect(res.status).toBe(200);
expect((res.body as { code?: string })?.code).toBeUndefined();
expect(mockAuditInsert).not.toHaveBeenCalled();
});
});

// =====================================================================
// 3. Triple-gate: non-founder / expired / tampered / flag-off are ignored
// =====================================================================

describe("central View-As guard — triple gate (mutation is NOT blocked when a gate fails)", () => {
it("GATE 1 (flag OFF): a valid cookie does NOT block — VIEW_AS_ENABLED off is a full no-op", async () => {
vi.stubEnv("VIEW_AS_ENABLED", "");
const res = await middleware(createRequest("/api/v1/settings/profile", {
method: "POST",
cookies: { [VIEW_AS_COOKIE]: scopeCookie() },
}));
expect(res.status).toBe(200);
expect(mockAuditInsert).not.toHaveBeenCalled();
});

it("GATE 3 (founder): a NON-founder with a valid cookie is NOT blocked (forged cookie can't lock a real user out)", async () => {
mockGetUser.mockResolvedValue({ data: { user: CLIENT } });
const res = await middleware(createRequest("/api/v1/settings/profile", {
method: "POST",
cookies: { [VIEW_AS_COOKIE]: scopeCookie() },
}));
expect(res.status).toBe(200);
expect(mockAuditInsert).not.toHaveBeenCalled();
});

it("GATE 2 (expiry): an EXPIRED scope cookie does NOT block", async () => {
const res = await middleware(createRequest("/api/v1/settings/profile", {
method: "POST",
cookies: { [VIEW_AS_COOKIE]: scopeCookie(validScope({ exp: Date.now() - 1_000 })) },
}));
expect(res.status).toBe(200);
expect(mockAuditInsert).not.toHaveBeenCalled();
});

it("GATE 2 (signature): a TAMPERED cookie does NOT block", async () => {
const good = scopeCookie();
const tampered = "X" + good.slice(1); // break the signed body
const res = await middleware(createRequest("/api/v1/settings/profile", {
method: "POST",
cookies: { [VIEW_AS_COOKIE]: tampered },
}));
expect(res.status).toBe(200);
expect(mockAuditInsert).not.toHaveBeenCalled();
});

it("no cookie at all → normal mutation passes (200)", async () => {
const res = await middleware(createRequest("/api/v1/settings/profile", { method: "POST" }));
expect(res.status).toBe(200);
expect(mockAuditInsert).not.toHaveBeenCalled();
});
});

// =====================================================================
// 4. Audit failure must never let the write through (fail-closed)
// =====================================================================

describe("central View-As guard — fail-closed on audit error", () => {
it("still returns 403 even if the audit insert rejects", async () => {
mockAuditInsert.mockRejectedValue(new Error("db down"));
const res = await middleware(createRequest("/api/v1/settings/profile", {
method: "POST",
cookies: { [VIEW_AS_COOKIE]: scopeCookie() },
}));
expect(res.status).toBe(403);
expect((res.body as { code?: string })?.code).toBe("VIEW_AS_READ_ONLY");
});
});
Loading