Public: anyone can read this; the crew can change it.

GET /api/v1/user/assigned carries an ETag and answers a repeat with 304 until something assigned to the viewer changes #9185

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/9177-get-api-v1-user-assigned-carries-an-etag → main 2 commits fork

  • Merged. scout/9177-get-api-v1-user-assigned-carries-an-etag was merged into main as c1638e6.

Review

Sign in to review this change.
2 files changed +168 −1
src/http/api/user.ts +11 −1
@@ -27,6 +27,7 @@ import { sshCloneUrl } from "../../ssh/command.ts";
2727 import { type RepoSource, type SourceError, sourceFor } from "../../view/source.ts";
2828 import { connectMessage, contributeFailure, contributionJson, editFilesFrom, jobJson, keyedPage, keyedWindow, loadRepo, oauthCallbackUri, originFacts, originJson, remoteRepoJson } from "../api.ts";
2929 import type { Ctx, Handler } from "../context.ts";
30 import { holds, weakEtag } from "../etag.ts";
3031 import { clientAddress } from "../peer.ts";
3132 import { apiError, body, json, notFound, publicProfile, publicUser, requireAccount, requireScope, respondAccess, str, unauthorized } from "../respond.ts";
3233 import type { Router } from "../router.ts";
@@ -757,7 +758,16 @@ export function userRoutes(router: Router<Handler>): void {
757758 }
758759 return out;
759760 });
760 return timePhase(ctx.request, "render", () => json({ items, next_cursor: null }));
761 // Tagged by its own bytes: a retitle, a close or an assignment changes
762 // them, and the viewer's own list is all they cover. Only reached with
763 // a session or a token of `ro` or more.
764 return timePhase(ctx.request, "render", () => {
765 const text = JSON.stringify({ items, next_cursor: null });
766 const etag = weakEtag(text);
767 const headers = { etag, "cache-control": "private, no-cache", vary: "authorization, cookie" };
768 if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers });
769 return new Response(text, { headers: { "content-type": "application/json", ...headers } });
770 });
761771 });
762772
763773 // The first three steps on a spawned forge, as MCP `next_steps`: empty
test/assigned-etag.test.ts added +157
@@ -0,0 +1,157 @@
1 /** GET /api/v1/user/assigned carries an ETag and answers a repeat with 304 (#9177). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createIssue, setIssueState, updateIssueText } from "../src/core/issues.ts";
9 import { createToken } from "../src/core/tokens.ts";
10 import { createUser } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13
14 quietLogs();
15
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19 let aliceId = "";
20 let repoId = "";
21 let aliceTok = "";
22 let aliceRo = "";
23 let aliceWrite = "";
24 let bobTok = "";
25 const cells = process.env["FORGE_CELLS"];
26 const URL_ = "/api/v1/user/assigned";
27 const get = (tok: string | null, headers: Record<string, string> = {}) =>
28 fetch(`${servers.url}${URL_}`, { headers: { ...(tok ? { authorization: `Bearer ${tok}` } : {}), ...headers } });
29 const tagOf = async (tok: string) => {
30 const res = await get(tok);
31 expect(res.status).toBe(200);
32 await res.text();
33 return res.headers.get("etag")!;
34 };
35 const assign = (n: number, assignee: string | null) =>
36 fetch(`${servers.url}/api/v1/repos/alice/a/issues/${n}/assignee`, {
37 method: "PUT",
38 headers: { authorization: `Bearer ${aliceTok}`, "content-type": "application/json" },
39 body: JSON.stringify({ assignee }),
40 });
41 const open = (title: string) => {
42 const made = createIssue(app.db, { repoId, authorId: aliceId, title });
43 if (!made.ok) throw new Error("issue");
44 return made.value;
45 };
46 /** Expect the held tag to be stale: 200 with a different tag; return the new tag. */
47 const moved = async (tok: string, held: string) => {
48 const res = await get(tok, { "if-none-match": held });
49 expect(res.status).toBe(200);
50 expect(res.headers.get("etag")).not.toBe(held);
51 await res.text();
52 return res.headers.get("etag")!;
53 };
54
55 beforeAll(async () => {
56 process.env["FORGE_CELLS"] = "off";
57 dataDir = await mkdtemp(join(tmpdir(), "forge-assigned-etag-"));
58 app = createApp({ dataDir, port: 0 });
59 servers = await serve(app);
60 const mk = async (username: string, first: boolean) => {
61 const made = await createUser(app.db, {
62 origin: "seed",
63 trusted: true,
64 username,
65 email: `${username}@forge.local`,
66 password: "a-good-passphrase",
67 adminIfFirst: first,
68 });
69 if (!made.ok) throw new Error(String(made.error));
70 return made.value;
71 };
72 const alice = await mk("alice", true);
73 const bob = await mk("bob", false);
74 aliceId = alice.id;
75 const r = await createRepo(app, alice, { name: "a", visibility: "public" });
76 if (!r.ok) throw new Error("repo");
77 repoId = r.value.id;
78 aliceTok = createToken(app.db, alice.id, "t", "admin").token;
79 aliceRo = createToken(app.db, alice.id, "r", "ro").token;
80 aliceWrite = createToken(app.db, alice.id, "w", "rw").token;
81 bobTok = createToken(app.db, bob.id, "b", "ro").token;
82 });
83 afterAll(async () => {
84 await servers.stop();
85 app.close();
86 if (cells === undefined) delete process.env["FORGE_CELLS"];
87 else process.env["FORGE_CELLS"] = cells;
88 await rm(dataDir, { recursive: true, force: true });
89 });
90
91 test("a weak tag, private no-cache, vary", async () => {
92 const res = await get(aliceRo);
93 expect(res.status).toBe(200);
94 expect(res.headers.get("etag")).toMatch(/^W\/"/);
95 expect(res.headers.get("cache-control")).toBe("private, no-cache");
96 expect(res.headers.get("vary")).toContain("authorization, cookie");
97 expect(((await res.json()) as { items: unknown[] }).items).toEqual([]);
98 });
99
100 test("a repeat is a bodiless 304 with the floor", async () => {
101 const tag = await tagOf(aliceRo);
102 const again = await get(aliceRo, { "if-none-match": tag });
103 expect(again.status).toBe(304);
104 expect(again.headers.get("etag")).toBe(tag);
105 expect(again.headers.get("cache-control")).toBe("private, no-cache");
106 expect(again.headers.get("vary")).toContain("authorization, cookie");
107 expect(again.headers.get("x-content-type-options")).toBe("nosniff");
108 expect(again.headers.get("referrer-policy")).not.toBeNull();
109 expect(await again.text()).toBe("");
110 });
111
112 test("assign, retitle, close and unassign each move the tag", async () => {
113 const issue = open("first");
114 let tag = await tagOf(aliceRo);
115 expect((await assign(issue.number, "alice")).status).toBe(200);
116 tag = await moved(aliceRo, tag);
117 const again = await get(aliceRo, { "if-none-match": tag });
118 expect(again.status).toBe(304);
119
120 expect(updateIssueText(app.db, issue.id, { title: "renamed" }).ok).toBe(true);
121 tag = await moved(aliceRo, tag);
122
123 const second = open("second");
124 expect((await assign(second.number, "alice")).status).toBe(200);
125 tag = await moved(aliceRo, tag);
126 expect((await assign(second.number, null)).status).toBe(200);
127 tag = await moved(aliceRo, tag);
128
129 setIssueState(app.db, issue.id, "closed");
130 await moved(aliceRo, tag);
131 });
132
133 test("two users never share a tag for different lists", async () => {
134 const issue = open("mine");
135 expect((await assign(issue.number, "alice")).status).toBe(200);
136 const a = await tagOf(aliceRo);
137 const b = await tagOf(bobTok);
138 expect(a).not.toBe(b);
139 const cross = await get(bobTok, { "if-none-match": a });
140 expect(cross.status).toBe(200);
141 await cross.text();
142 });
143
144 test("no session, or a token short of ro, is as today with no tag", async () => {
145 const anon = await get(null, { "if-none-match": "*" });
146 expect(anon.status).toBe(401);
147 expect(anon.headers.get("etag")).toBeNull();
148 await anon.text();
149 // A scope above ro still reads (and is tagged); a bad credential is refused.
150 const rw = await get(aliceWrite);
151 expect(rw.status).toBe(200);
152 await rw.text();
153 const bad = await get("not-a-token", { "if-none-match": "*" });
154 expect(bad.status).toBe(401);
155 expect(bad.headers.get("etag")).toBeNull();
156 await bad.text();
157 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs