Public: anyone can read this; the crew can change it.
The caller's SSH keys, pins and organizations answer `{ items, next_cursor }` #5373
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5043-the-caller-s-ssh-keys-pins-and-organizat → main 10 commits fork 1 thread · 1 unresolved
12 files
changed +266
−31
docs/ORIGINS.md +1 −1
| @@ -607,7 +607,7 @@ POST /api/v1/repos/:o/:n/sync sync this mirror now; on a fork, | ||
| 607 | 607 | POST /api/v1/repos/:o/:n/origin/reconnect {originId} → repo JSON; re-attach a |
| 608 | 608 | detached mirror (admin token, repo |
| 609 | 609 | admin, own origin; else 404/422 code) |
| 610 | GET /api/v1/user/pins PUT/DELETE /api/v1/user/pins/:owner/:name | |
| 610 | GET /api/v1/user/pins ({items, next_cursor}; ?limit=1..200, ?cursor=) PUT/DELETE /api/v1/user/pins/:owner/:name | |
| 611 | 611 | |
| 612 | 612 | POST /api/v1/migrate {source: {url} | {originId, fullName}, |
| 613 | 613 | owner?, name?, visibility?, |
src/cli/api.ts +23 −4
| @@ -111,10 +111,29 @@ export class ForgeApi { | ||
| 111 | 111 | * pin, so the client is useful against an older workbench. |
| 112 | 112 | */ |
| 113 | 113 | async pins(): Promise<ApiResult<Pin[]>> { |
| 114 | const out = await this.call<unknown>("/api/v1/user/pins"); | |
| 115 | if (out.ok) return ok(this.toPins(out.value)); | |
| 116 | if (out.error.status !== 404 && out.error.status !== 501) return out; | |
| 117 | this.pinsUnavailable = true; | |
| 114 | const pins: Pin[] = []; | |
| 115 | let cursor: string | undefined; | |
| 116 | for (let page = 0; page < 50; page++) { | |
| 117 | const query = cursor === undefined ? "" : `?cursor=${cursor}`; | |
| 118 | const out = await this.call<unknown>(`/api/v1/user/pins${query}`); | |
| 119 | if (!out.ok) { | |
| 120 | if (out.error.status !== 404 && out.error.status !== 501) return out; | |
| 121 | this.pinsUnavailable = true; | |
| 122 | return this.ownedRepos(); | |
| 123 | } | |
| 124 | // An older forge answers the whole list as a bare array. | |
| 125 | if (Array.isArray(out.value)) return ok(this.toPins(out.value)); | |
| 126 | const body = (out.value ?? {}) as { items?: unknown; next_cursor?: unknown }; | |
| 127 | pins.push(...this.toPins(body.items)); | |
| 128 | if (typeof body.next_cursor !== "string" || body.next_cursor === "") | |
| 129 | return ok(pins); | |
| 130 | cursor = encodeURIComponent(body.next_cursor); | |
| 131 | } | |
| 132 | return ok(pins); | |
| 133 | } | |
| 134 | ||
| 135 | /** The fallback for a forge with no pins endpoint: every repo you own. */ | |
| 136 | private async ownedRepos(): Promise<ApiResult<Pin[]>> { | |
| 118 | 137 | const me = await this.whoami(); |
| 119 | 138 | if (!me.ok) return me; |
| 120 | 139 | const repos = await this.repos(); |
src/http/api.ts +13 −4
| @@ -1045,6 +1045,9 @@ function listQuery(ctx: Ctx) { | ||
| 1045 | 1045 | }; |
| 1046 | 1046 | } |
| 1047 | 1047 | |
| 1048 | /** Room for the longest key: a pin's `owner/name` (up to ~40 + 1 + 100 characters). */ | |
| 1049 | const KEYED_CURSOR_MAX = 160; | |
| 1050 | ||
| 1048 | 1051 | function offsetRefused(error: "invalid_limit" | "invalid_cursor"): Response { |
| 1049 | 1052 | return error === "invalid_limit" |
| 1050 | 1053 | ? apiError(error, 422, `limit is a whole number from 1 to ${OFFSET_LIMIT_MAX}`) |
| @@ -1062,7 +1065,7 @@ function keyedWindow(ctx: Ctx): { limit: number; cursor: string | null } | Respo | ||
| 1062 | 1065 | if (limit < 1 || limit > LIST_LIMIT_MAX) |
| 1063 | 1066 | return apiError("invalid_limit", 422, `limit is a whole number from 1 to ${LIST_LIMIT_MAX}`); |
| 1064 | 1067 | const cursor = q.get("cursor"); |
| 1065 | if (cursor !== null && (cursor.length < 1 || cursor.length > 100)) | |
| 1068 | if (cursor !== null && (cursor.length < 1 || cursor.length > KEYED_CURSOR_MAX)) | |
| 1066 | 1069 | return apiError("invalid_cursor", 422, "cursor is the next_cursor of a previous page"); |
| 1067 | 1070 | return { limit, cursor }; |
| 1068 | 1071 | } |
| @@ -1995,10 +1998,12 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1995 | 1998 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 1996 | 1999 | if (!ctx.auth.user) return unauthorized(); |
| 1997 | 2000 | const user = ctx.auth.user; |
| 2001 | const window = keyedWindow(ctx); | |
| 2002 | if (window instanceof Response) return window; | |
| 1998 | 2003 | const orgs = timePhase(ctx.request, "orgs", () => |
| 1999 | 2004 | listOrgsForUser(ctx.app.db, user.id).map(publicProfile), |
| 2000 | 2005 | ); |
| 2001 | return timePhase(ctx.request, "render", () => json(orgs)); | |
| 2006 | return timePhase(ctx.request, "render", () => json(keyedPage(orgs, (o) => o.username, window))); | |
| 2002 | 2007 | }); |
| 2003 | 2008 | |
| 2004 | 2009 | // SSH keys — what the SSH listener authenticates against. Public keys are |
| @@ -2007,8 +2012,10 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2007 | 2012 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 2008 | 2013 | const user = ctx.auth.user; |
| 2009 | 2014 | if (!user) return unauthorized(); |
| 2015 | const window = keyedWindow(ctx); | |
| 2016 | if (window instanceof Response) return window; | |
| 2010 | 2017 | const keys = timePhase(ctx.request, "keys", () => listKeys(ctx.app.db, user.id)); |
| 2011 | return timePhase(ctx.request, "render", () => json(keys.map(publicKey))); | |
| 2018 | return timePhase(ctx.request, "render", () => json(keyedPage(keys.map(publicKey), (k) => k.id, window))); | |
| 2012 | 2019 | }); |
| 2013 | 2020 | router.add("POST", "/api/v1/user/keys", async (ctx) => { |
| 2014 | 2021 | const denied = requireScope(ctx, "rw"); |
| @@ -5188,6 +5195,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 5188 | 5195 | router.add("GET", "/api/v1/user/pins", (ctx) => { |
| 5189 | 5196 | if (!ctx.auth.user) return unauthorized(); |
| 5190 | 5197 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 5198 | const window = keyedWindow(ctx); | |
| 5199 | if (window instanceof Response) return window; | |
| 5191 | 5200 | const host = ctx.app.config.host ?? ctx.url.hostname; |
| 5192 | 5201 | const sshPort = ctx.app.config.sshPort; |
| 5193 | 5202 | const user = ctx.auth.user; |
| @@ -5221,7 +5230,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 5221 | 5230 | }, |
| 5222 | 5231 | ]; |
| 5223 | 5232 | }); |
| 5224 | return json(items); | |
| 5233 | return json(keyedPage(items, (p) => `${p.owner}/${p.name}`, window)); | |
| 5225 | 5234 | }); |
| 5226 | 5235 | }); |
| 5227 | 5236 |
test/api-keys-timing.test.ts +1 −1
| @@ -54,5 +54,5 @@ test("the keys list names session, keys, render in order", async () => { | ||
| 54 | 54 | const got = await phases("/api/v1/user/keys"); |
| 55 | 55 | expect(got.status).toBe(200); |
| 56 | 56 | expect(got.names).toEqual(["session", "keys", "render"]); |
| 57 | expect(Array.isArray(JSON.parse(got.body))).toBe(true); | |
| 57 | expect(Array.isArray(JSON.parse(got.body).items)).toBe(true); | |
| 58 | 58 | }); |
test/api-list-shape.test.ts −3
| @@ -17,9 +17,6 @@ import { seedAdmin } from "./helpers/admin.ts"; | ||
| 17 | 17 | |
| 18 | 18 | /** Routes that answer a bare array today → the open issue that changes them. */ |
| 19 | 19 | const BARE: Record<string, string> = { |
| 20 | "/api/v1/user/orgs": "#5043", | |
| 21 | "/api/v1/user/keys": "#5043", | |
| 22 | "/api/v1/user/pins": "#5043", | |
| 23 | 20 | "/api/v1/repos/:owner/:name/forks": "#4843", |
| 24 | 21 | "/api/v1/repos/:owner/:name/labels": "#4843", |
| 25 | 22 | "/api/v1/repos/:owner/:name/hooks": "#4843", |
test/api-pins-timing.test.ts +1 −1
| @@ -57,5 +57,5 @@ test("the pins list names session, pins, render in order", async () => { | ||
| 57 | 57 | const got = await phases("/api/v1/user/pins"); |
| 58 | 58 | expect(got.status).toBe(200); |
| 59 | 59 | expect(got.names).toEqual(["session", "pins", "render"]); |
| 60 | expect(Array.isArray(JSON.parse(got.body))).toBe(true); | |
| 60 | expect(Array.isArray(JSON.parse(got.body).items)).toBe(true); | |
| 61 | 61 | }); |
test/cli-pins-pages.test.ts added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | import { afterEach, expect, test } from "bun:test"; | |
| 2 | import { ForgeApi } from "../src/cli/api.ts"; | |
| 3 | ||
| 4 | let server: ReturnType<typeof Bun.serve> | undefined; | |
| 5 | afterEach(() => server?.stop(true)); | |
| 6 | ||
| 7 | const pin = (i: number) => ({ owner: "alice", name: `r${i}`, cloneUrl: `http://x/alice/r${i}.git` }); | |
| 8 | ||
| 9 | function stub(handler: (url: URL) => Response): ForgeApi { | |
| 10 | server = Bun.serve({ port: 0, fetch: (req) => handler(new URL(req.url)) }); | |
| 11 | return new ForgeApi(`http://127.0.0.1:${server.port}`, "t"); | |
| 12 | } | |
| 13 | ||
| 14 | test("pins() follows next_cursor across pages", async () => { | |
| 15 | const seen: (string | null)[] = []; | |
| 16 | const api = stub((url) => { | |
| 17 | const cursor = url.searchParams.get("cursor"); | |
| 18 | seen.push(cursor); | |
| 19 | return cursor === null | |
| 20 | ? Response.json({ items: [pin(1), pin(2)], next_cursor: "2" }) | |
| 21 | : Response.json({ items: [pin(3)], next_cursor: null }); | |
| 22 | }); | |
| 23 | const out = await api.pins(); | |
| 24 | expect(out.ok && out.value.map((p) => p.name)).toEqual(["r1", "r2", "r3"]); | |
| 25 | expect(seen).toEqual([null, "2"]); | |
| 26 | }); | |
| 27 | ||
| 28 | test("pins() takes a bare array from an older forge as the whole list", async () => { | |
| 29 | const api = stub(() => Response.json([pin(1), pin(2)])); | |
| 30 | const out = await api.pins(); | |
| 31 | expect(out.ok && out.value.map((p) => p.name)).toEqual(["r1", "r2"]); | |
| 32 | }); |
test/keys.test.ts +1 −1
| @@ -253,7 +253,7 @@ describe.skipIf(NO_KEYGEN)("ssh keys", () => { | ||
| 253 | 253 | const listed = await fetch(`${base}/api/v1/user/keys`, { |
| 254 | 254 | headers: headers(ro), |
| 255 | 255 | }); |
| 256 | const keys = (await listed.json()) as { id: string }[]; | |
| 256 | const keys = ((await listed.json()) as { items: { id: string }[] }).items; | |
| 257 | 257 | expect(keys.map((k) => k.id)).toEqual([key.id]); |
| 258 | 258 | |
| 259 | 259 | expect((await fetch(`${base}/api/v1/user/keys`)).status).toBe(401); |
test/origins-security-auth.test.ts +12 −6
| @@ -1090,9 +1090,11 @@ describe("authorization across origins, mirrors, pins and disconnect", () => { | ||
| 1090 | 1090 | // Carol sees none of Alice's pins, cannot remove them, and cannot pin a |
| 1091 | 1091 | // repo she cannot see — which does not exist for her. |
| 1092 | 1092 | expect( |
| 1093 | await read<unknown[]>( | |
| 1094 | await seen(await api("/api/v1/user/pins", { as: "carol" })), | |
| 1095 | ), | |
| 1093 | ( | |
| 1094 | await read<{ items: unknown[] }>( | |
| 1095 | await seen(await api("/api/v1/user/pins", { as: "carol" })), | |
| 1096 | ) | |
| 1097 | ).items, | |
| 1096 | 1098 | ).toEqual([]); |
| 1097 | 1099 | expect( |
| 1098 | 1100 | ( |
| @@ -1119,12 +1121,16 @@ describe("authorization across origins, mirrors, pins and disconnect", () => { | ||
| 1119 | 1121 | ).status, |
| 1120 | 1122 | ).toBe(404); |
| 1121 | 1123 | expect( |
| 1122 | await read<unknown[]>(await api("/api/v1/user/pins", { as: "bob" })), | |
| 1124 | ( | |
| 1125 | await read<{ items: unknown[] }>( | |
| 1126 | await api("/api/v1/user/pins", { as: "bob" }), | |
| 1127 | ) | |
| 1128 | ).items, | |
| 1123 | 1129 | ).toEqual([]); |
| 1124 | const mine = await read<{ name: string }[]>( | |
| 1130 | const mine = await read<{ items: { name: string }[] }>( | |
| 1125 | 1131 | await seen(await api("/api/v1/user/pins")), |
| 1126 | 1132 | ); |
| 1127 | expect(mine.map((p) => p.name).sort()).toEqual(["secret", "widgets"]); | |
| 1133 | expect(mine.items.map((p) => p.name).sort()).toEqual(["secret", "widgets"]); | |
| 1128 | 1134 | expect( |
| 1129 | 1135 | app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM pin").get()!.n, |
| 1130 | 1136 | ).toBe(2); |
test/origins.test.ts +9 −6
| @@ -700,11 +700,13 @@ describe("origins: connecting, mirroring, writing through", () => { | ||
| 700 | 700 | expect( |
| 701 | 701 | (await api("/api/v1/user/pins/alice/widgets", { method: "PUT" })).status, |
| 702 | 702 | ).toBe(200); |
| 703 | const pins = (await (await api("/api/v1/user/pins")).json()) as { | |
| 704 | owner: string; | |
| 705 | name: string; | |
| 706 | cloneUrl: string; | |
| 707 | }[]; | |
| 703 | const { items: pins, next_cursor } = (await ( | |
| 704 | await api("/api/v1/user/pins") | |
| 705 | ).json()) as { | |
| 706 | items: { owner: string; name: string; cloneUrl: string }[]; | |
| 707 | next_cursor: string | null; | |
| 708 | }; | |
| 709 | expect(next_cursor).toBeNull(); | |
| 708 | 710 | expect(pins).toHaveLength(1); |
| 709 | 711 | expect(pins[0]).toMatchObject({ owner: "alice", name: "widgets" }); |
| 710 | 712 | expect(pins[0]!.cloneUrl.endsWith("/alice/widgets.git")).toBe(true); |
| @@ -730,7 +732,8 @@ describe("origins: connecting, mirroring, writing through", () => { | ||
| 730 | 732 | .status, |
| 731 | 733 | ).toBe(204); |
| 732 | 734 | expect( |
| 733 | ((await (await api("/api/v1/user/pins")).json()) as unknown[]).length, | |
| 735 | ((await (await api("/api/v1/user/pins")).json()) as { items: unknown[] }) | |
| 736 | .items.length, | |
| 734 | 737 | ).toBe(0); |
| 735 | 738 | }); |
| 736 | 739 |
test/parity.test.ts +6 −4
| @@ -850,9 +850,11 @@ describe("parity: full control through actions and the API", () => { | ||
| 850 | 850 | ).toBe(204); |
| 851 | 851 | |
| 852 | 852 | const myOrgs = await call("GET", "/api/v1/user/orgs", { token: aliceRo }); |
| 853 | expect(myOrgs.list.map((o) => o.username)).toEqual(["acme"]); | |
| 853 | const orgItems = (r: { json: Json }) => (r.json.items ?? []) as Json[]; | |
| 854 | expect(orgItems(myOrgs).map((o) => o.username)).toEqual(["acme"]); | |
| 855 | expect(myOrgs.json.next_cursor).toBeNull(); | |
| 854 | 856 | expect( |
| 855 | (await call("GET", "/api/v1/user/orgs", { token: bobRw })).list, | |
| 857 | orgItems(await call("GET", "/api/v1/user/orgs", { token: bobRw })), | |
| 856 | 858 | ).toEqual([]); |
| 857 | 859 | for (const kind of [ |
| 858 | 860 | "team.created", |
| @@ -913,9 +915,9 @@ describe("parity: full control through actions and the API", () => { | ||
| 913 | 915 | expect(res.json.error).toBe("invalid_limit"); |
| 914 | 916 | } |
| 915 | 917 | |
| 916 | // The cursor is only compared: any 1..100 characters, nothing else. | |
| 918 | // The cursor is only compared: any 1..160 characters, nothing else. | |
| 917 | 919 | for (const path of ["teams", "teams/devs/members"]) |
| 918 | for (const bad of ["", "x".repeat(101)]) { | |
| 920 | for (const bad of ["", "x".repeat(161)]) { | |
| 919 | 921 | const res = await call("GET", `/api/v1/orgs/acme/${path}?cursor=${bad}`, { |
| 920 | 922 | token: aliceAdmin, |
| 921 | 923 | }); |
test/user-lists-page.test.ts added +167
| @@ -0,0 +1,167 @@ | ||
| 1 | /** The caller's SSH keys, pins and orgs answer `{ items, next_cursor }` with a bounded limit and a cursor. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createOrg } from "../src/actions/orgs.ts"; | |
| 8 | import { pinRepo } from "../src/actions/origins.ts"; | |
| 9 | import { createRepo } from "../src/actions/repos.ts"; | |
| 10 | import { addSshKey, deleteSshKey } from "../src/actions/users.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 14 | import { proveAll } from "./helpers/verified.ts"; | |
| 15 | ||
| 16 | type Page = { items: Record<string, unknown>[]; next_cursor: string | null }; | |
| 17 | ||
| 18 | /** A well-formed ed25519 line with random key bytes: no ssh-keygen needed. */ | |
| 19 | function ed25519Line(comment: string): string { | |
| 20 | const field = (b: Uint8Array) => { | |
| 21 | const out = new Uint8Array(4 + b.length); | |
| 22 | new DataView(out.buffer).setUint32(0, b.length); | |
| 23 | out.set(b, 4); | |
| 24 | return out; | |
| 25 | }; | |
| 26 | const algo = field(new TextEncoder().encode("ssh-ed25519")); | |
| 27 | const pub = field(crypto.getRandomValues(new Uint8Array(32))); | |
| 28 | const blob = new Uint8Array(algo.length + pub.length); | |
| 29 | blob.set(algo); | |
| 30 | blob.set(pub, algo.length); | |
| 31 | return `ssh-ed25519 ${Buffer.from(blob).toString("base64")} ${comment}`; | |
| 32 | } | |
| 33 | ||
| 34 | describe("the caller's lists are pages", () => { | |
| 35 | let dir: string; | |
| 36 | let app: App; | |
| 37 | let servers: Servers; | |
| 38 | let token = ""; | |
| 39 | let aliceUser: Parameters<typeof deleteSshKey>[1]; | |
| 40 | ||
| 41 | const get = (path: string) => | |
| 42 | fetch(`${servers.url}${path}`, { headers: { authorization: `Bearer ${token}` } }); | |
| 43 | ||
| 44 | /** Every page to the end with `limit=2`; the pages' sizes and all names. */ | |
| 45 | async function walk(path: string, name: string) { | |
| 46 | const sizes: number[] = []; | |
| 47 | const names: unknown[] = []; | |
| 48 | let cursor: string | null = null; | |
| 49 | for (let i = 0; i < 10; i++) { | |
| 50 | const q: string = `limit=2${cursor === null ? "" : `&cursor=${cursor}`}`; | |
| 51 | const page = (await (await get(`${path}?${q}`)).json()) as Page; | |
| 52 | sizes.push(page.items.length); | |
| 53 | names.push(...page.items.map((x) => x[name])); | |
| 54 | cursor = page.next_cursor; | |
| 55 | if (cursor === null) break; | |
| 56 | } | |
| 57 | return { sizes, names }; | |
| 58 | } | |
| 59 | ||
| 60 | beforeAll(async () => { | |
| 61 | dir = await mkdtemp(join(tmpdir(), "forge-user-lists-")); | |
| 62 | app = createApp({ dataDir: dir, port: 0 }); | |
| 63 | servers = await serve(app); | |
| 64 | const a = await seedAdmin(app, { | |
| 65 | username: "alice", | |
| 66 | email: "[email protected]", | |
| 67 | password: "a-long-passphrase", | |
| 68 | }); | |
| 69 | if (!a.ok) throw new Error("seed failed"); | |
| 70 | const alice = a.value; | |
| 71 | aliceUser = alice; | |
| 72 | proveAll(app); | |
| 73 | for (const org of ["org-a", "org-b", "org-c"]) createOrg(app, alice, org); | |
| 74 | for (const name of ["r1", "r2", "r3"]) { | |
| 75 | const made = await createRepo(app, alice, { name, visibility: "private" }); | |
| 76 | if (!made.ok) throw new Error(`repo failed: ${made.error}`); | |
| 77 | pinRepo(app, alice, made.value); | |
| 78 | } | |
| 79 | { | |
| 80 | for (const n of ["k1", "k2", "k3"]) { | |
| 81 | const added = addSshKey(app, alice, { name: n, key: ed25519Line(n) }); | |
| 82 | if (!added.ok) throw new Error("key failed"); | |
| 83 | } | |
| 84 | } | |
| 85 | const res = await fetch(`${servers.url}/api/v1/tokens`, { | |
| 86 | method: "POST", | |
| 87 | headers: { "content-type": "application/json" }, | |
| 88 | body: JSON.stringify({ | |
| 89 | adminConfirm: true, | |
| 90 | username: "alice", | |
| 91 | password: "a-long-passphrase", | |
| 92 | name: "t", | |
| 93 | scope: "ro", | |
| 94 | }), | |
| 95 | }); | |
| 96 | token = ((await res.json()) as { token: string }).token; | |
| 97 | }); | |
| 98 | ||
| 99 | afterAll(async () => { | |
| 100 | servers.stop(); | |
| 101 | app.close(); | |
| 102 | await rm(dir, { recursive: true, force: true }); | |
| 103 | }); | |
| 104 | ||
| 105 | const lists: [string, string, number][] = [ | |
| 106 | ["/api/v1/user/orgs", "username", 3], | |
| 107 | ["/api/v1/user/pins", "name", 3], | |
| 108 | ["/api/v1/user/keys", "name", 3], | |
| 109 | ]; | |
| 110 | ||
| 111 | for (const [path, field, total] of lists) { | |
| 112 | test(`${path}: shape, paging to the end, bad input refused`, async () => { | |
| 113 | const whole = (await (await get(path)).json()) as Page; | |
| 114 | expect(Array.isArray(whole.items)).toBe(true); | |
| 115 | expect(whole.items).toHaveLength(total); | |
| 116 | expect(whole.next_cursor).toBeNull(); | |
| 117 | ||
| 118 | const walked = await walk(path, field); | |
| 119 | expect(walked.sizes).toEqual([2, 1]); | |
| 120 | expect(walked.names.sort()).toEqual(whole.items.map((x) => x[field]).sort()); | |
| 121 | ||
| 122 | for (const bad of ["limit=0", "limit=201", "limit=abc", "limit=-1", "limit=1.5"]) { | |
| 123 | const res = await get(`${path}?${bad}`); | |
| 124 | expect(res.status).toBe(422); | |
| 125 | expect(((await res.json()) as { error: string }).error).toBe("invalid_limit"); | |
| 126 | } | |
| 127 | const res = await get(`${path}?cursor=${"x".repeat(161)}`); | |
| 128 | expect(res.status).toBe(422); | |
| 129 | expect(((await res.json()) as { error: string }).error).toBe("invalid_cursor"); | |
| 130 | expect((await get(`${path}?limit=200`)).status).toBe(200); | |
| 131 | expect((await fetch(`${servers.url}${path}`)).status).toBe(401); | |
| 132 | }); | |
| 133 | } | |
| 134 | ||
| 135 | test("a key deleted between two pages skips nothing", async () => { | |
| 136 | const first = (await (await get("/api/v1/user/keys?limit=1")).json()) as Page; | |
| 137 | expect(first.items).toHaveLength(1); | |
| 138 | expect(first.next_cursor).not.toBeNull(); | |
| 139 | const gone = deleteSshKey(app, aliceUser, String(first.items[0]!.id)); | |
| 140 | expect(gone.ok).toBe(true); | |
| 141 | const second = (await ( | |
| 142 | await get(`/api/v1/user/keys?limit=5&cursor=${encodeURIComponent(first.next_cursor!)}`) | |
| 143 | ).json()) as Page; | |
| 144 | expect(second.items).toHaveLength(2); | |
| 145 | expect(second.next_cursor).toBeNull(); | |
| 146 | }); | |
| 147 | ||
| 148 | test("a pin with a 100-character name pages across its boundary", async () => { | |
| 149 | const name = "n".repeat(100); | |
| 150 | const made = await createRepo(app, aliceUser, { name, visibility: "private" }); | |
| 151 | if (!made.ok) throw new Error(`repo failed: ${made.error}`); | |
| 152 | pinRepo(app, aliceUser, made.value); | |
| 153 | const seen: unknown[] = []; | |
| 154 | let cursor: string | null = null; | |
| 155 | for (let i = 0; i < 10; i++) { | |
| 156 | const q: string = `limit=1${cursor === null ? "" : `&cursor=${encodeURIComponent(cursor)}`}`; | |
| 157 | const res = await get(`/api/v1/user/pins?${q}`); | |
| 158 | expect(res.status).toBe(200); | |
| 159 | const page = (await res.json()) as Page; | |
| 160 | seen.push(...page.items.map((x) => x.name)); | |
| 161 | cursor = page.next_cursor; | |
| 162 | if (cursor === null) break; | |
| 163 | } | |
| 164 | expect(seen).toHaveLength(4); | |
| 165 | expect(seen).toContain(name); | |
| 166 | }); | |
| 167 | }); |
new above
offline
Minor: once the client has fetched 50 pages, it returns a partial list without saying so. 50 pages at the default limit of 50 is 2,500 pins, so this is unlikely to happen. Passing
?limit=200would make the cap 10,000 and save round trips.