Files
captoys/webapp/tests/cookie.test.ts
dckc 3f711a5f4b capsite/webapp: the cookie's store is the owner's, apart from the instance
Letting the instance id rotate the cookie let its holder (the cache, or
whoever learns the id) swap in another account's session, which the
owner's logout would not stop. Now mint makes two things: the instance,
and a header store (lib's MapStoreDo, shaped ("cookie", { keyShape:
"cookie", valueShape: M.string() })). The instance id is authority to
use the cookie; the store id, for the account owner, to rotate (PUT
/store/<storeId>/cookie, or WebAppFactory.store) or revoke (DELETE).
Instances read the store's read-only view at every send, via init's new
headerStore.

This replaces the in-instance cookie store and its migration: instances
minted earlier keep their cookie in init.headers, untouched, and simply
cannot rotate. The MapStoreDo class arrives as wrangler migration v2
(additive).

Generated-by: Claude Opus 5.5
2026-10-09 02:32:22 -05:00

132 lines
4.9 KiB
TypeScript

import { env } from "cloudflare:workers";
import { runInDurableObject } from "cloudflare:test";
import { afterEach, describe, expect, it, vi } from "vitest";
import worker, { type WebAppInit } from "../src/index.js";
type TestEnv = {
WEBAPP_DO: DurableObjectNamespace<import("../src/index.js").WebAppDo>;
MAP_STORE_DO: DurableObjectNamespace<import("../src/index.js").MapStoreDo>;
};
const testEnv = env as unknown as TestEnv;
const URL_ = "https://diigo.example/api/";
/** the cookie header of each outbound fetch */
function spyCookies(): (string | null)[] {
const seen: (string | null)[] = [];
vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => {
seen.push(new Headers(init?.headers).get("cookie"));
return new Response("ok");
});
return seen;
}
afterEach(() => {
vi.restoreAllMocks();
});
/** the worker's HTTP surface, as a JSON client */
const call = (path: string, method: string, body?: unknown) =>
worker.fetch(
new Request(`https://webapp.example${path}`, {
method,
body: body === undefined ? undefined : JSON.stringify(body),
headers: { "content-type": "application/json" },
}),
testEnv,
);
const mint = async (init: WebAppInit) => {
const { id, storeId } = (await (await call("/", "POST", init)).json()) as { id: string; storeId: string };
const instance = testEnv.WEBAPP_DO.get(testEnv.WEBAPP_DO.idFromString(id));
return { id, storeId, instance };
};
/** the failure of a call: its error status, or what it threw; "" if it succeeds */
async function errorOf(response: Promise<Response>): Promise<string> {
try {
const res = await response;
return res.ok ? "" : `${res.status} ${await res.text()}`;
} catch (err) {
return String(err);
}
}
/** `init` as stored */
const storedInit = (stub: DurableObjectStub) =>
runInDurableObject(stub, (_obj, state) => state.storage.kv.get<WebAppInit>("init"));
describe("cookie rotation", () => {
it("mint puts the cookie in the header store, not init; sends carry it", async () => {
const seen = spyCookies();
const { storeId, instance } = await mint({
url: URL_,
headers: { Cookie: "session=a", accept: "application/json" },
});
expect(await storedInit(instance)).toEqual({
url: URL_,
headers: { accept: "application/json" },
headerStore: storeId,
});
using app = await instance.handle();
await app.get();
expect(seen).toEqual(["session=a"]);
});
it("the store id rotates under handles already out, and revokes", async () => {
const seen = spyCookies();
const { storeId, instance } = await mint({ url: URL_, headers: { cookie: "session=a" } });
using app = await instance.handle();
await app.pathjoin("x").get();
expect(await (await call(`/store/${storeId}/cookie`, "PUT", "session=b")).json()).toEqual({ ok: true });
await app.pathjoin("x").get();
expect(await (await call(`/store/${storeId}/cookie`, "DELETE")).json()).toEqual({ ok: true });
await app.get();
expect(seen).toEqual(["session=a", "session=b", null]);
});
it("the instance id reaches no store: its holder cannot swap in another session", async () => {
const seen = spyCookies();
const { id, instance } = await mint({ url: URL_, headers: { cookie: "session=alice" } });
expect(await errorOf(call(`/store/${id}/cookie`, "PUT", "session=mallory"))).not.toBe("");
expect((await call(`/at/${id}/cookie`, "POST", { cookie: "session=mallory" })).status).toBe(404);
using app = await instance.handle();
await app.get();
expect(seen).toEqual(["session=alice"]);
});
it("the store's shapes admit only the cookie; errors carry no values", async () => {
const { storeId } = await mint({ url: URL_ });
const err = await errorOf(call(`/store/${storeId}/authorization`, "PUT", "Bearer s3kr1t"));
expect(err).toContain('cookie: key: must match \\"cookie\\"');
expect(err).not.toContain("s3kr1t");
expect(await errorOf(call(`/store/${storeId}/cookie`, "PUT", 42))).toContain("must match M.string()");
});
it("an instance minted before header stores keeps its cookie in init, untouched", async () => {
const seen = spyCookies();
const instance = testEnv.WEBAPP_DO.get(testEnv.WEBAPP_DO.newUniqueId());
const old = { url: URL_, headers: { cookie: "session=old" }, msPerCall: 0 };
await runInDurableObject(instance, (_obj, state) => state.storage.put("init", old));
using app = await instance.handle();
await app.get();
expect(seen).toEqual(["session=old"]);
expect(await storedInit(instance)).toEqual(old);
});
it("a fixed header contradicting the stored cookie fails at send, naming no values", async () => {
spyCookies();
const { instance } = await mint({ url: URL_, headers: { cookie: "session=a" } });
using app = await instance.handle();
const err = await app
.withHeader("cookie", "session=mine")
.get()
.then(
() => "",
(e: unknown) => String(e),
);
expect(err).toContain("headers: contradicts on cookie");
expect(err).not.toContain("session=");
});
});