WIP: webapp: rotate the diigo cookie in place #1
No files matched your search
@@ -109,10 +109,10 @@ narrative.
|
|||||||
weekly page renders against prod.
|
weekly page renders against prod.
|
||||||
- [ ] **6. Cookie expiry**: when diigo's session ends, head refreshes
|
- [ ] **6. Cookie expiry**: when diigo's session ends, head refreshes
|
||||||
fail; the public page keeps serving what's stored (say so on the
|
fail; the public page keeps serving what's stored (say so on the
|
||||||
page, don't fail silently). Rotation is manual: re-mint via
|
page, don't fail silently). Rotation is in place, keeping the
|
||||||
`/_connect`. Open question: can an existing cache be re-wired to
|
webapp id (so the cache's wiring and ids hold): `POST
|
||||||
a new webapp id (keeping its cache id and public username), or
|
/at/<id>/cookie` with the new cookie (webapp README, "The cookie
|
||||||
does re-mint mean a new cache? Prefer re-wire — durable ids.
|
store"). Still open: a rotate form in diigo-cache's UI.
|
||||||
- [ ] **7. bib sync #195**: repeat the pattern for the next
|
- [ ] **7. bib sync #195**: repeat the pattern for the next
|
||||||
read-access need (webapp mints for any origin; the cache's
|
read-access need (webapp mints for any origin; the cache's
|
||||||
item shape is diigo-specific): [the zotero goal tree](zotero-checklist.md).
|
item shape is diigo-specific): [the zotero goal tree](zotero-checklist.md).
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ grows past a form or two.
|
|||||||
- `GET /form?render=1&…` — the same form for a proxying worker (diigo-cache's `/_connect`): the action is left as an `ACTION` placeholder for the proxy to point at its own relay.
|
- `GET /form?render=1&…` — the same form for a proxying worker (diigo-cache's `/_connect`): the action is left as an `ACTION` placeholder for the proxy to point at its own relay.
|
||||||
- `POST /` — mint. Form-encoded (that page) or JSON `{url, headers, msPerCall}` (service-style). Responds `{id}` (and `x-webapp-id`). The id is *not* logged: possession is authority.
|
- `POST /` — mint. Form-encoded (that page) or JSON `{url, headers, msPerCall}` (service-style). Responds `{id}` (and `x-webapp-id`). The id is *not* logged: possession is authority.
|
||||||
- `POST /at/<id>` — drive an instance by capability: JSON `{path?, query?, method?, body?}` → the fluent chain → `{status, text}` (or `{error}`; messages carry no header/param values).
|
- `POST /at/<id>` — drive an instance by capability: JSON `{path?, query?, method?, body?}` → the fluent chain → `{status, text}` (or `{error}`; messages carry no header/param values).
|
||||||
|
- `POST /at/<id>/cookie` — rotate the instance's cookie in place: JSON `{cookie}` → `{ok: true}`. The id that drives an instance can also replace its cookie; the account owner revokes by logging out.
|
||||||
|
|
||||||
## RPC surface (same-account workers; binding `entrypoint: "WebAppFactory"`)
|
## RPC surface (same-account workers; binding `entrypoint: "WebAppFactory"`)
|
||||||
|
|
||||||
@@ -22,9 +23,20 @@ grows past a form or two.
|
|||||||
promise pipelining makes a whole `pathjoin().query().get()` chain a
|
promise pipelining makes a whole `pathjoin().query().get()` chain a
|
||||||
single round trip
|
single round trip
|
||||||
- `at(id)` — the fluent stub for a previously-minted instance
|
- `at(id)` — the fluent stub for a previously-minted instance
|
||||||
|
- `cookies(id)` — the instance's cookie store (a MapStore from
|
||||||
|
[the capsite lib](../lib/src/store.ts) with key shape `"cookie"`):
|
||||||
|
`set("cookie", …)` rotates it under every handle already out
|
||||||
|
|
||||||
|
## The cookie store
|
||||||
|
|
||||||
|
An instance is fixed at creation except for its cookie, which lives in a
|
||||||
|
store rather than in `init`; every send reads it. Instances minted before
|
||||||
|
the store held their cookie in `init.headers`; the first `handle()` moves
|
||||||
|
it over in one transaction.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
pnpm test # cookie store + rotation (vitest, workerd pool)
|
||||||
npx wrangler dev # → its own ready line (usually :8787 with the
|
npx wrangler dev # → its own ready line (usually :8787 with the
|
||||||
# two-config diigo-cache run; standalone, :8788)
|
# two-config diigo-cache run; standalone, :8788)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "webapp",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"types": "wrangler types",
|
||||||
|
"lint": "wrangler types --check && tsc -p tsconfig.json && tsc -p tests/tsconfig.json",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/vitest-plugin": "1.3.6",
|
||||||
|
"typescript": "^5.9.0",
|
||||||
|
"vitest": "4.1.11",
|
||||||
|
"wrangler": "^4.148.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2240
File diff suppressed because it is too large.
Load diff
+115
-10
@@ -7,13 +7,21 @@
|
|||||||
// DurableObjectNamespace/DurableObjectId/DurableObjectStub are ambient types
|
// DurableObjectNamespace/DurableObjectId/DurableObjectStub are ambient types
|
||||||
// in worker-configuration.d.ts (not exports of "cloudflare:workers")
|
// in worker-configuration.d.ts (not exports of "cloudflare:workers")
|
||||||
import { RpcTarget, DurableObject, WorkerEntrypoint } from "cloudflare:workers";
|
import { RpcTarget, DurableObject, WorkerEntrypoint } from "cloudflare:workers";
|
||||||
|
import { M } from "../../lib/src/patterns.js";
|
||||||
|
import { makeMapStore, type MapStore } from "../../lib/src/store.js";
|
||||||
|
|
||||||
export type Query = Record<string, string | number>;
|
export type Query = Record<string, string | number>;
|
||||||
type Headers = Record<string, string>;
|
type Headers = Record<string, string>;
|
||||||
|
|
||||||
|
/** headers looked up at each send, e.g. a store's read-only view: whoever
|
||||||
|
* holds the store can rotate a credential under handles already out */
|
||||||
|
export type HeaderMap = { entries(): [string, unknown][] | Promise<[string, unknown][]> };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* init for a new instance — fixed at creation. An instance is immutable
|
* init for a new instance — fixed at creation, except that a `cookie`
|
||||||
* authority: no re-config methods, only attenuation via the fluent chain.
|
* header goes to the instance's rotatable cookie store (see
|
||||||
|
* {@link WebAppDo}). Otherwise immutable authority: no re-config methods,
|
||||||
|
* only attenuation via the fluent chain.
|
||||||
*/
|
*/
|
||||||
export interface WebAppInit {
|
export interface WebAppInit {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -29,6 +37,7 @@ export interface FluentGet {
|
|||||||
withHeaders(h: Headers): FluentGet;
|
withHeaders(h: Headers): FluentGet;
|
||||||
withHeader(key: string, value: string): FluentGet;
|
withHeader(key: string, value: string): FluentGet;
|
||||||
cookies(cookie: string): FluentGet;
|
cookies(cookie: string): FluentGet;
|
||||||
|
withHeaderMap(map: HeaderMap): FluentGet;
|
||||||
get(): Promise<string>;
|
get(): Promise<string>;
|
||||||
post(body: string): Promise<string>;
|
post(body: string): Promise<string>;
|
||||||
}
|
}
|
||||||
@@ -57,16 +66,19 @@ export interface FluentGet {
|
|||||||
export class WebApp extends RpcTarget implements FluentGet {
|
export class WebApp extends RpcTarget implements FluentGet {
|
||||||
#url: string;
|
#url: string;
|
||||||
#headers: Headers;
|
#headers: Headers;
|
||||||
|
#maps: readonly HeaderMap[];
|
||||||
#send: (url: string, headers: Headers, method: "GET" | "POST", body?: string) => Promise<string>;
|
#send: (url: string, headers: Headers, method: "GET" | "POST", body?: string) => Promise<string>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
url: string,
|
url: string,
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
|
maps: readonly HeaderMap[],
|
||||||
send: (url: string, headers: Headers, method: "GET" | "POST", body?: string) => Promise<string>,
|
send: (url: string, headers: Headers, method: "GET" | "POST", body?: string) => Promise<string>,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.#url = url;
|
this.#url = url;
|
||||||
this.#headers = headers;
|
this.#headers = headers;
|
||||||
|
this.#maps = maps;
|
||||||
this.#send = send;
|
this.#send = send;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,13 +97,13 @@ export class WebApp extends RpcTarget implements FluentGet {
|
|||||||
throw new Error(`pathjoin: ${ref} not within ${hereNoQuery.href}`);
|
throw new Error(`pathjoin: ${ref} not within ${hereNoQuery.href}`);
|
||||||
}
|
}
|
||||||
const baseParams: Query = Object.fromEntries(here.searchParams);
|
const baseParams: Query = Object.fromEntries(here.searchParams);
|
||||||
return new WebApp(unionQuery(there.href, baseParams), this.#headers, this.#send);
|
return new WebApp(unionQuery(there.href, baseParams), this.#headers, this.#maps, this.#send);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Union headers, flower-style (see {@link WebApp}): a shared key with a
|
/** Union headers, flower-style (see {@link WebApp}): a shared key with a
|
||||||
* different value throws; the same value is fine. */
|
* different value throws; the same value is fine. */
|
||||||
withHeaders(h: Headers): WebApp {
|
withHeaders(h: Headers): WebApp {
|
||||||
return new WebApp(this.#url, unionHeaders(this.#headers, h), this.#send);
|
return new WebApp(this.#url, unionHeaders(this.#headers, h), this.#maps, this.#send);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** one key, flower-style (see {@link WebApp}) */
|
/** one key, flower-style (see {@link WebApp}) */
|
||||||
@@ -105,15 +117,34 @@ export class WebApp extends RpcTarget implements FluentGet {
|
|||||||
|
|
||||||
/** Union params into the existing query, flower-style (see {@link WebApp}). */
|
/** Union params into the existing query, flower-style (see {@link WebApp}). */
|
||||||
query(params: Query): WebApp {
|
query(params: Query): WebApp {
|
||||||
return new WebApp(unionQuery(this.#url, params), this.#headers, this.#send);
|
return new WebApp(unionQuery(this.#url, params), this.#headers, this.#maps, this.#send);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add headers read from `map` at each send, unioned flower-style (see
|
||||||
|
* {@link WebApp}) with the rest — so a contradiction surfaces at send. */
|
||||||
|
withHeaderMap(map: HeaderMap): WebApp {
|
||||||
|
return new WebApp(this.#url, this.#headers, [...this.#maps, map], this.#send);
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(): Promise<string> {
|
async get(): Promise<string> {
|
||||||
return this.#send(this.#url, this.#headers, "GET");
|
return this.#send(this.#url, await this.#headersNow(), "GET");
|
||||||
}
|
}
|
||||||
|
|
||||||
async post(body: string): Promise<string> {
|
async post(body: string): Promise<string> {
|
||||||
return this.#send(this.#url, this.#headers, "POST", body);
|
return this.#send(this.#url, await this.#headersNow(), "POST", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #headersNow(): Promise<Headers> {
|
||||||
|
let headers = this.#headers;
|
||||||
|
for (const map of this.#maps) {
|
||||||
|
const entries = await map.entries();
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
// key name only: header values must not appear in Error messages
|
||||||
|
if (typeof value !== "string") throw new Error(`headers: ${key} is not a string`);
|
||||||
|
}
|
||||||
|
headers = unionHeaders(headers, Object.fromEntries(entries) as Headers);
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +180,12 @@ class Gate {
|
|||||||
* storage, surviving eviction (instance memory evaporates when the DO naps;
|
* storage, surviving eviction (instance memory evaporates when the DO naps;
|
||||||
* storage is where "this instance means this URL" durably lives).
|
* storage is where "this instance means this URL" durably lives).
|
||||||
*
|
*
|
||||||
|
* The cookie is the exception to "fixed at creation": it lives in a store
|
||||||
|
* (`store:cookie:cookie`) rather than in `init`, and every send reads it,
|
||||||
|
* so whoever holds the instance id can rotate it (see {@link cookies})
|
||||||
|
* without a re-mint. Instances minted before that kept it in
|
||||||
|
* `init.headers`; {@link handle} moves it over on first use.
|
||||||
|
*
|
||||||
* @see https://developers.cloudflare.com/durable-objects/ — newUniqueId is
|
* @see https://developers.cloudflare.com/durable-objects/ — newUniqueId is
|
||||||
* unforgeable (128-bit, inderivable); idFromName by contrast is ambient:
|
* unforgeable (128-bit, inderivable); idFromName by contrast is ambient:
|
||||||
* any worker with the namespace binding can recompute it
|
* any worker with the namespace binding can recompute it
|
||||||
@@ -172,11 +209,21 @@ export class WebAppDo extends DurableObject<{ WEBAPP?: never }> {
|
|||||||
}
|
}
|
||||||
return; // idempotent for an identical re-configure
|
return; // idempotent for an identical re-configure
|
||||||
}
|
}
|
||||||
await this.ctx.storage.put("init", init);
|
const { cookie, rest } = splitCookie(init.headers ?? {});
|
||||||
|
this.ctx.storage.transactionSync(() => {
|
||||||
|
this.ctx.storage.kv.put("init", { ...init, headers: rest });
|
||||||
|
if (cookie !== undefined) this.cookies().init("cookie", cookie);
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
await this.#ready;
|
await this.#ready;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** the instance's cookie, with full authority: `set` rotates it under
|
||||||
|
* every handle already out */
|
||||||
|
cookies(): MapStore<string> {
|
||||||
|
return makeMapStore<string>("cookie", { keyShape: "cookie", valueShape: M.string() }, this.ctx.storage.kv);
|
||||||
|
}
|
||||||
|
|
||||||
/** load init from storage; rejects while unconfigured */
|
/** load init from storage; rejects while unconfigured */
|
||||||
async #load(): Promise<WebAppInit> {
|
async #load(): Promise<WebAppInit> {
|
||||||
const init = await this.ctx.storage.get<WebAppInit>("init");
|
const init = await this.ctx.storage.get<WebAppInit>("init");
|
||||||
@@ -189,7 +236,7 @@ export class WebAppDo extends DurableObject<{ WEBAPP?: never }> {
|
|||||||
/** the fluent stub handed to callers, wired to a fresh gate per
|
/** the fluent stub handed to callers, wired to a fresh gate per
|
||||||
* activation (handles within one activation share it) */
|
* activation (handles within one activation share it) */
|
||||||
async handle(): Promise<FluentGet> {
|
async handle(): Promise<FluentGet> {
|
||||||
const init = await this.#load();
|
const init = this.#migrateCookie(await this.#load());
|
||||||
const gate = new Gate(() => sleep(init.msPerCall ?? 0));
|
const gate = new Gate(() => sleep(init.msPerCall ?? 0));
|
||||||
const send = async (
|
const send = async (
|
||||||
url: string,
|
url: string,
|
||||||
@@ -207,8 +254,33 @@ export class WebAppDo extends DurableObject<{ WEBAPP?: never }> {
|
|||||||
}
|
}
|
||||||
return res.text();
|
return res.text();
|
||||||
};
|
};
|
||||||
return new WebApp(init.url, init.headers ?? {}, send);
|
return new WebApp(init.url, init.headers ?? {}, [this.cookies().readOnly()], send);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** move a cookie stored in `init.headers` to the cookie store, in one
|
||||||
|
* transaction; returns init without it */
|
||||||
|
#migrateCookie(init: WebAppInit): WebAppInit {
|
||||||
|
const { cookie, rest } = splitCookie(init.headers ?? {});
|
||||||
|
if (cookie === undefined) return init;
|
||||||
|
const migrated = { ...init, headers: rest };
|
||||||
|
this.ctx.storage.transactionSync(() => {
|
||||||
|
const cookies = this.cookies();
|
||||||
|
if (!cookies.has("cookie")) cookies.init("cookie", cookie);
|
||||||
|
this.ctx.storage.kv.put("init", migrated);
|
||||||
|
});
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** split the cookie (any case of the header name) from the other headers */
|
||||||
|
function splitCookie(headers: Headers): { cookie?: string; rest: Headers } {
|
||||||
|
const rest: Headers = {};
|
||||||
|
let cookie: string | undefined;
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (key.toLowerCase() === "cookie") cookie = value;
|
||||||
|
else rest[key] = value;
|
||||||
|
}
|
||||||
|
return { cookie, rest };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** the worker's whole HTTP surface (the default export and the
|
/** the worker's whole HTTP surface (the default export and the
|
||||||
@@ -246,6 +318,13 @@ const defaultFetch = async (
|
|||||||
return drive(await app, request);
|
return drive(await app, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rotate: POST /at/<id>/cookie with JSON {cookie}; the id that drives
|
||||||
|
// an instance can also replace its cookie
|
||||||
|
const rotate = url.pathname.match(/^\/at\/([A-Za-z0-9-]+)\/cookie$/);
|
||||||
|
if (rotate && request.method === "POST") {
|
||||||
|
return rotateCookie(env.WEBAPP_DO.get(env.WEBAPP_DO.idFromString(rotate[1])), request);
|
||||||
|
}
|
||||||
|
|
||||||
return new Response("not found", { status: 404 });
|
return new Response("not found", { status: 404 });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -368,6 +447,25 @@ async function drive(app: FluentGet, request: Request): Promise<Response> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** the /at/<id>/cookie protocol: JSON {cookie} → {ok} (or {error}; no values) */
|
||||||
|
async function rotateCookie(instance: DurableObjectStub<WebAppDo>, request: Request): Promise<Response> {
|
||||||
|
let cookie: unknown;
|
||||||
|
try {
|
||||||
|
({ cookie } = (await request.json()) as { cookie?: unknown });
|
||||||
|
} catch (err) {
|
||||||
|
return Response.json({ error: `bad input: ${String(err)}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof cookie !== "string" || !cookie) {
|
||||||
|
return Response.json({ error: "bad input: cookie must be a non-empty string" }, { status: 400 });
|
||||||
|
}
|
||||||
|
using cookies = await instance.cookies();
|
||||||
|
if (await cookies.has("cookie")) await cookies.set("cookie", cookie);
|
||||||
|
else await cookies.init("cookie", cookie);
|
||||||
|
// note: neither id nor cookie logged
|
||||||
|
console.log({ event: "rotate" });
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
function unionHeaders(base: Headers, more: Headers): Headers {
|
function unionHeaders(base: Headers, more: Headers): Headers {
|
||||||
const merged = { ...base };
|
const merged = { ...base };
|
||||||
for (const [key, value] of Object.entries(more)) {
|
for (const [key, value] of Object.entries(more)) {
|
||||||
@@ -434,6 +532,13 @@ export class WebAppFactory extends WorkerEntrypoint {
|
|||||||
const ns = this.env.WEBAPP_DO;
|
const ns = this.env.WEBAPP_DO;
|
||||||
return ns.get(ns.idFromString(id)).handle();
|
return ns.get(ns.idFromString(id)).handle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** the cookie store of a previously-obtained id: holding the id is
|
||||||
|
* authority to rotate the cookie, not just to use it */
|
||||||
|
async cookies(id: string) {
|
||||||
|
const ns = this.env.WEBAPP_DO;
|
||||||
|
return ns.get(ns.idFromString(id)).cookies();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function sleep(ms: number): Promise<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((r) => setTimeout(r, ms));
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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> };
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
const fresh = () => testEnv.WEBAPP_DO.get(testEnv.WEBAPP_DO.newUniqueId());
|
||||||
|
|
||||||
|
/** `init` as stored */
|
||||||
|
const storedInit = (stub: DurableObjectStub) =>
|
||||||
|
runInDurableObject(stub, (_obj, state) => state.storage.kv.get<WebAppInit>("init"));
|
||||||
|
|
||||||
|
describe("cookie rotation", () => {
|
||||||
|
it("mint keeps the cookie out of init; sends carry it", async () => {
|
||||||
|
const seen = spyCookies();
|
||||||
|
const stub = fresh();
|
||||||
|
await stub.configure({ url: URL_, headers: { Cookie: "session=a", accept: "application/json" } });
|
||||||
|
expect(await storedInit(stub)).toEqual({ url: URL_, headers: { accept: "application/json" } });
|
||||||
|
using app = await stub.handle();
|
||||||
|
await app.get();
|
||||||
|
expect(seen).toEqual(["session=a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a rotation reaches handles already out", async () => {
|
||||||
|
const seen = spyCookies();
|
||||||
|
const stub = fresh();
|
||||||
|
await stub.configure({ url: URL_, headers: { cookie: "session=a" } });
|
||||||
|
using app = await stub.handle();
|
||||||
|
await app.pathjoin("x").get();
|
||||||
|
using cookies = await stub.cookies();
|
||||||
|
await cookies.set("cookie", "session=b");
|
||||||
|
await app.pathjoin("x").get();
|
||||||
|
expect(seen).toEqual(["session=a", "session=b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an instance minted before rotation moves its cookie to the store on first use", async () => {
|
||||||
|
const seen = spyCookies();
|
||||||
|
const stub = fresh();
|
||||||
|
// the shape configure() stored until the cookie store
|
||||||
|
await runInDurableObject(stub, (_obj, state) =>
|
||||||
|
state.storage.put("init", { url: URL_, headers: { cookie: "session=old" }, msPerCall: 0 }),
|
||||||
|
);
|
||||||
|
using app = await stub.handle();
|
||||||
|
await app.get();
|
||||||
|
expect(await storedInit(stub)).toEqual({ url: URL_, headers: {}, msPerCall: 0 });
|
||||||
|
using cookies = await stub.cookies();
|
||||||
|
expect(await cookies.get("cookie")).toBe("session=old");
|
||||||
|
await cookies.set("cookie", "session=new");
|
||||||
|
using again = await stub.handle();
|
||||||
|
await again.get();
|
||||||
|
expect(seen).toEqual(["session=old", "session=new"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /at/<id>/cookie rotates, by the same id that drives", async () => {
|
||||||
|
const seen = spyCookies();
|
||||||
|
const id = testEnv.WEBAPP_DO.newUniqueId();
|
||||||
|
const stub = testEnv.WEBAPP_DO.get(id);
|
||||||
|
await stub.configure({ url: URL_ });
|
||||||
|
const rotate = (cookie: string) =>
|
||||||
|
worker.fetch(
|
||||||
|
new Request(`https://webapp.example/at/${id}/cookie`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ cookie }),
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
testEnv,
|
||||||
|
);
|
||||||
|
expect(await (await rotate("session=first")).json()).toEqual({ ok: true });
|
||||||
|
expect(await (await rotate("session=second")).json()).toEqual({ ok: true });
|
||||||
|
using app = await stub.handle();
|
||||||
|
await app.get();
|
||||||
|
expect(seen).toEqual(["session=second"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a fixed header contradicting the stored cookie fails at send, naming no values", async () => {
|
||||||
|
spyCookies();
|
||||||
|
const stub = fresh();
|
||||||
|
await stub.configure({ url: URL_, headers: { cookie: "session=a" } });
|
||||||
|
using app = await stub.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=");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["@cloudflare/vitest-plugin/types"]
|
||||||
|
},
|
||||||
|
"include": ["./**/*", "../worker-configuration.d.ts"]
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
"lib": ["ES2022", "ESNext.Disposable"],
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": []
|
"types": []
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineProject } from "vitest/config";
|
||||||
|
import { cloudflareTest } from "@cloudflare/vitest-plugin";
|
||||||
|
|
||||||
|
export default defineProject({
|
||||||
|
plugins: [cloudflareTest({ wrangler: { configPath: "./wrangler.jsonc" } })],
|
||||||
|
test: {
|
||||||
|
include: ["tests/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user