marmar-context / web / context / test / netlify.test.ts
netlify.test.ts
Raw
import { afterEach, describe, expect, it, vi } from "vitest";
import { createPrivateKey, generateKeyPairSync, sign } from "node:crypto";
import { DurableSessions, type BlobStore } from "../server/durable-sessions";
import { accessOwner } from "../server/access";
import { createNetlifyHandler } from "../server/netlify-handler";
import { CASES } from "../server/cases";
import { assess, extractLocal } from "../server/context";
class MemoryBlobs implements BlobStore {
  rows = new Map<string, { data: any; etag: string }>();
  serial = 0;
  async get(key: string) {
    return structuredClone(this.rows.get(key)?.data ?? null);
  }
  async getWithMetadata(key: string) {
    return structuredClone(this.rows.get(key) ?? null);
  }
  async setJSON(
    key: string,
    value: unknown,
    options?: { onlyIfMatch?: string; onlyIfNew?: boolean }
  ) {
    const existing = this.rows.get(key);
    if (
      (options?.onlyIfNew && existing) ||
      (options?.onlyIfMatch && existing?.etag !== options.onlyIfMatch)
    )
      return { modified: false };
    this.rows.set(key, {
      data: structuredClone(value),
      etag: String(++this.serial),
    });
    return { modified: true };
  }
  async delete(key: string) {
    this.rows.delete(key);
  }
  async list(options?: { prefix?: string }) {
    return {
      blobs: [...this.rows.keys()]
        .filter((key) => key.startsWith(options?.prefix || ""))
        .map((key) => ({ key })),
    };
  }
}
const sources = CASES[0].sources;
const owner = "reviewer-one";
const reviewFor = (draft: ReturnType<typeof extractLocal>) =>
  Object.fromEntries(draft.facts.map((f) => [f.id, "include" as const]));
afterEach(() => {
  vi.unstubAllGlobals();
  vi.unstubAllEnvs();
  vi.useRealTimers();
});
describe("Netlify durable review", () => {
  it("survives a cold function instance and preserves report provenance", async () => {
    const store = new MemoryBlobs();
    const first = new DurableSessions(store);
    const draft = await first.keepDraft(extractLocal(sources), sources, owner);
    const cold = new DurableSessions(store);
    expect(await cold.getDraft(draft.id, sources, owner)).toEqual(draft);
    const review = reviewFor(draft);
    const result = await cold.keepReport(
      draft.id,
      assess(sources, review, draft.fingerprint, true, draft),
      review,
      owner
    );
    const report = await new DurableSessions(store).getReport(
      result.reportId!,
      owner
    );
    expect(report.sources).toEqual(sources);
    expect(report.assessment.contextual.rulesReviewed).toBe(false);
  });
  it("rejects cross-reviewer retrieval and altered original sources", async () => {
    const sessions = new DurableSessions(new MemoryBlobs());
    const draft = await sessions.keepDraft(
      extractLocal(sources),
      sources,
      owner
    );
    await expect(
      sessions.getDraft(draft.id, sources, "other")
    ).rejects.toThrow();
    await expect(
      sessions.getDraft(
        draft.id,
        [
          { ...sources[0], text: sources[0].text + "\nChanged" },
          ...sources.slice(1),
        ],
        owner
      )
    ).rejects.toThrow(/Sources changed/);
    const review = reviewFor(draft),
      result = await sessions.keepReport(
        draft.id,
        assess(sources, review, draft.fingerprint, true, draft),
        review,
        owner
      );
    await expect(
      sessions.getReport(result.reportId!, "other")
    ).rejects.toThrow();
  });
  it("expires and cleans old server data while leaving fresh records", async () => {
    let now = 100;
    const store = new MemoryBlobs();
    const sessions = new DurableSessions(store, () => now);
    const draft = await sessions.keepDraft(
      extractLocal(sources),
      sources,
      owner
    );
    now += 7200001;
    await expect(sessions.getDraft(draft.id, sources, owner)).rejects.toThrow(
      /expired/
    );
    const fresh = await sessions.keepDraft(
      extractLocal(sources),
      sources,
      owner
    );
    expect(await sessions.purge()).toBe(1);
    expect(await sessions.getDraft(fresh.id, sources, owner)).toBeTruthy();
  });
  it("retains clarification source links across cold instances", async () => {
    const clarified = [
      ...sources,
      {
        id: "clarification-test",
        title: "Confirmed correction",
        kind: "Reviewer clarification",
        date: "2026-10-23",
        text: "Patient: Nora Demo",
        clarification: {
          sourceId: sources[0].id,
          line: 1,
          quote: sources[0].text.split("\n")[0],
          reason: "Confirm name",
          confirmedBy: "Synthetic fixture only",
        },
      },
    ];
    const store = new MemoryBlobs();
    const draft = await new DurableSessions(store).keepDraft(
      extractLocal(clarified),
      clarified,
      owner
    );
    const next = await new DurableSessions(store).getDraft(
      draft.id,
      clarified,
      owner
    );
    expect(next.fingerprint).toBe(draft.fingerprint);
    expect(
      (await store.get("draft/" + draft.id)).sources.at(-1).clarification
    ).toEqual(clarified.at(-1)!.clarification);
  });
  it("makes previous report obsolete after a newer assessment", async () => {
    const sessions = new DurableSessions(new MemoryBlobs());
    const draft = await sessions.keepDraft(
      extractLocal(sources),
      sources,
      owner
    );
    const review = reviewFor(draft),
      assessment = assess(sources, review, draft.fingerprint, true, draft);
    const old = await sessions.keepReport(draft.id, assessment, review, owner);
    const current = await sessions.keepReport(
      draft.id,
      assessment,
      review,
      owner
    );
    await expect(sessions.getReport(old.reportId!, owner)).rejects.toThrow(
      /no longer current/
    );
    expect(await sessions.getReport(current.reportId!, owner)).toBeTruthy();
  });
  it("does not oversubscribe reviewer quota under concurrent cold requests", async () => {
    const store = new MemoryBlobs();
    const results = await Promise.allSettled(
      Array.from({ length: 20 }, () =>
        new DurableSessions(store, () => 123).limit(owner)
      )
    );
    expect(
      results.filter((r) => r.status === "fulfilled").length
    ).toBeGreaterThan(0);
    expect(
      results.filter((r) => r.status === "fulfilled").length
    ).toBeLessThanOrEqual(6);
  });
  it("preserves daily allowance across cold instances", async () => {
    let now = 0;
    const store = new MemoryBlobs();
    for (let i = 0; i < 120; i++) {
      now = i * 60000;
      await new DurableSessions(store, () => now).limit(owner);
    }
    await expect(
      new DurableSessions(store, () => now + 60000).limit(owner)
    ).rejects.toThrow(/allowance/);
  });
});
describe("Netlify request boundary", () => {
  const make = () => {
    const store = new MemoryBlobs();
    return {
      store,
      handler: createNetlifyHandler(
        () => store,
        async () => owner
      ),
    };
  };
  const req = (path: string, body?: unknown) =>
    new Request("https://context.marmar.life/api/" + path, {
      method: body === undefined ? "GET" : "POST",
      headers: body === undefined ? {} : { "Content-Type": "application/json" },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
  it("runs extraction, gated review, assessment and export across handlers", async () => {
    const { store, handler } = make();
    const draft = await (
      await handler(
        req("extract", { sources, syntheticOnly: true, useModel: false })
      )
    ).json();
    const review = reviewFor(draft);
    const payload = {
      sources,
      syntheticOnly: true,
      draftId: draft.id,
      review,
      fingerprint: draft.fingerprint,
      acknowledgeUnparsed: true,
    };
    expect(
      (await handler(req("assess", { ...payload, review: {} }))).status
    ).toBe(400);
    const cold = createNetlifyHandler(
      () => store,
      async () => owner
    );
    const response = await cold(req("assess", payload));
    expect(response.status).toBe(200);
    const result = await response.json();
    expect((await cold(req("reports/" + result.reportId))).status).toBe(200);
  });
  it("blocks unrelated origins, real-data input and client model configuration", async () => {
    const { handler } = make();
    expect(
      (await handler(new Request("https://evil.example/api/status"))).status
    ).toBe(403);
    expect(
      (await handler(req("extract", { sources, syntheticOnly: false }))).status
    ).toBe(400);
    expect((await handler(req("model", {}))).status).toBe(403);
  });
  it("fails closed when authentication is absent", async () => {
    const handler = createNetlifyHandler(() => new MemoryBlobs());
    vi.stubEnv(
      "CONTEXT_ACCESS_ISSUER",
      "https://marmar-test.cloudflareaccess.com"
    );
    vi.stubEnv("CONTEXT_ACCESS_AUD", "test-audience");
    expect((await handler(req("status"))).status).toBe(401);
  });
});
describe("Cloudflare Access signature validation", () => {
  const keys = generateKeyPairSync("rsa", { modulusLength: 2048 });
  const issuer = "https://marmar-test.cloudflareaccess.com";
  const jwk = {
    ...keys.publicKey.export({ format: "jwk" }),
    kid: "test-key",
    alg: "RS256",
  };
  function token(overrides: Record<string, unknown> = {}, alg = "RS256") {
    const head = Buffer.from(JSON.stringify({ alg, kid: "test-key" })).toString(
      "base64url"
    );
    const body = Buffer.from(
      JSON.stringify({
        iss: issuer,
        aud: ["test-audience"],
        sub: "synthetic-user",
        email: "synthetic@example.invalid",
        exp: Math.floor(Date.now() / 1000) + 100,
        ...overrides,
      })
    ).toString("base64url");
    return (
      head +
      "." +
      body +
      "." +
      sign(
        "RSA-SHA256",
        Buffer.from(head + "." + body),
        keys.privateKey
      ).toString("base64url")
    );
  }
  const env = {
    CONTEXT_ACCESS_ISSUER: issuer,
    CONTEXT_ACCESS_AUD: "test-audience",
  };
  const request = (value: string) =>
    new Request("https://context.marmar.life/api/status", {
      headers: { "cf-access-jwt-assertion": value },
    });
  it("accepts correctly signed intended audience only", async () => {
    vi.stubGlobal(
      "fetch",
      vi.fn(async () => Response.json({ keys: [jwk] }))
    );
    expect(await accessOwner(request(token()), env)).toMatch(/^[a-f0-9]{64}$/);
    await expect(
      accessOwner(request(token({ aud: ["other"] })), env)
    ).rejects.toThrow(/sign-in/);
    await expect(accessOwner(request(token({ exp: 1 })), env)).rejects.toThrow(
      /sign-in/
    );
    await expect(
      accessOwner(request(token({ iss: "https://evil.example" })), env)
    ).rejects.toThrow(/sign-in/);
  });
  it("rejects unsigned, tampered and wrong-algorithm tokens", async () => {
    vi.stubGlobal(
      "fetch",
      vi.fn(async () => Response.json({ keys: [jwk] }))
    );
    await expect(
      accessOwner(request(token({}, "none")), env)
    ).rejects.toThrow();
    const parts = token().split(".");
    parts[1] = Buffer.from(JSON.stringify({ sub: "attacker" })).toString(
      "base64url"
    );
    await expect(accessOwner(request(parts.join(".")), env)).rejects.toThrow();
    await expect(accessOwner(request(""), env)).rejects.toThrow();
  });
});

it("fails closed when storage omits a concurrency version", async () => {
 const store = new MemoryBlobs();
 const sessions = new DurableSessions(store);
 const draft = await sessions.keepDraft(extractLocal(sources), sources, owner);
 for (const row of store.rows.values()) row.etag = "";
 await expect(sessions.getDraft(draft.id, sources, owner)).rejects.toThrow("verify");
 await store.setJSON(`quota/minute/${Math.floor(Date.now()/60000)}/global`,{count:1});
 for (const row of store.rows.values()) row.etag = "";
 await expect(sessions.limit(owner)).rejects.toThrow("verify");
});