From 8e20710ddd3c5ecc9fca4a9bd39f53c4c397b398 Mon Sep 17 00:00:00 2001 From: EC2 Default User Date: Thu, 9 Apr 2026 23:36:39 +0000 Subject: [PATCH] fix: restore no-token repository validation --- test/repository-service-page-view.test.ts | 145 ++++++++++++++++++++-- web/src/lib/repository-service.ts | 63 +++++++++- 2 files changed, 195 insertions(+), 13 deletions(-) diff --git a/test/repository-service-page-view.test.ts b/test/repository-service-page-view.test.ts index f1b4102..1636d42 100644 --- a/test/repository-service-page-view.test.ts +++ b/test/repository-service-page-view.test.ts @@ -11,9 +11,10 @@ const originalLiveStatusModule = await import(new URL("../web/src/lib/server/liv const originalQueueModule = await import(new URL("../web/src/lib/server/queue.ts?repository-service-original", import.meta.url).href) const originalReportsModule = await import(new URL("../web/src/lib/server/reports.ts?repository-service-original", import.meta.url).href) +const repoExistenceCache = new Map() const enqueueCalls: string[] = [] const touchCalls: Array<{ owner: string; repo: string; queuedNow: boolean }> = [] -const fetchCalls: string[] = [] +const fetchCalls: Array<{ url: string; init?: RequestInit }> = [] const repoRecordLookups: string[] = [] let databaseEnabled = true @@ -21,6 +22,7 @@ let queueEnabled = true let repoRecord: any = null let statusSnapshot: any = null let fetchStatus = 200 +let fetchHeaders: Record | undefined mock.module(databaseModulePath, () => ({ databaseConfigured: () => databaseEnabled, @@ -35,7 +37,14 @@ mock.module(queueModulePath, () => ({ enqueueCalls.push(fullName) return true }, - getRedisClient: async () => ({ get: async () => null, set: async () => "OK" }) as unknown, + getRedisClient: async () => + ({ + get: async (key: string) => repoExistenceCache.get(key) ?? null, + set: async (key: string, value: string) => { + repoExistenceCache.set(key, value) + return "OK" + }, + }) as unknown, queueConfigured: () => queueEnabled, })) @@ -82,6 +91,8 @@ beforeEach(() => { repoRecord = null statusSnapshot = null fetchStatus = 200 + fetchHeaders = undefined + repoExistenceCache.clear() enqueueCalls.length = 0 touchCalls.length = 0 fetchCalls.length = 0 @@ -90,15 +101,17 @@ beforeEach(() => { delete process.env.GITHUB_TOKEN process.env.REDIS_URL = "redis://example.test:6379" - globalThis.fetch = (async (input: Request | string | URL) => { + globalThis.fetch = (async (input: Request | string | URL, init?: RequestInit) => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url - fetchCalls.push(url) - return new Response(null, { status: fetchStatus }) + fetchCalls.push({ url, init }) + return new Response(null, { status: fetchStatus, headers: fetchHeaders }) }) as typeof fetch }) afterEach(() => { globalThis.fetch = originalFetch + delete process.env.GH_TOKEN + delete process.env.GITHUB_TOKEN delete process.env.REDIS_URL mock.module(databaseModulePath, () => originalDatabaseModule) mock.module(liveStatusModulePath, () => originalLiveStatusModule) @@ -107,27 +120,138 @@ afterEach(() => { }) describe("repository page view loading", () => { - test("queues a missing repo once and reuses stored queued state on later reads", async () => { + test("uses the tokenless HEAD probe, caches positive hits, and keeps read-only reuse side-effect free", async () => { const firstView = await getRepositoryPageView("schema-labs-ltd", "discofork") - const secondView = await getRepositoryPageView("schema-labs-ltd", "discofork") + + repoRecord = null + statusSnapshot = null + + const secondView = await readRepositoryView("schema-labs-ltd", "discofork") expect(firstView.kind).toBe("queued") expect(secondView.kind).toBe("queued") + expect(fetchCalls).toHaveLength(1) + expect(fetchCalls[0]).toMatchObject({ + url: "https://github.com/schema-labs-ltd/discofork", + init: expect.objectContaining({ method: "HEAD", redirect: "manual" }), + }) + expect(repoExistenceCache.get("discofork:github-repo-exists:schema-labs-ltd/discofork")).toBe("1") expect(enqueueCalls).toEqual(["schema-labs-ltd/discofork"]) expect(touchCalls).toEqual([{ owner: "schema-labs-ltd", repo: "discofork", queuedNow: true }]) }) + test("accepts case-only redirects from the public GitHub probe", async () => { + fetchStatus = 301 + fetchHeaders = { location: "https://github.com/Schema-Labs-Ltd/DiscoFork" } + + const view = await readRepositoryView("schema-labs-ltd", "discofork") + + expect(view.kind).toBe("queued") + expect(repoExistenceCache.get("discofork:github-repo-exists:schema-labs-ltd/discofork")).toBe("1") + expect(enqueueCalls).toEqual([]) + expect(touchCalls).toEqual([]) + }) + + test("rejects redirects that land on a different repository", async () => { + fetchStatus = 301 + fetchHeaders = { location: "https://github.com/schema-labs-ltd/discofork-renamed" } + + await expect(readRepositoryView("schema-labs-ltd", "discofork")).rejects.toBeInstanceOf(RepositoryNotFoundError) + + expect(repoExistenceCache.get("discofork:github-repo-exists:schema-labs-ltd/discofork")).toBe("0") + expect(enqueueCalls).toEqual([]) + expect(touchCalls).toEqual([]) + }) + + test("keeps tokenless reads available when the anonymous probe gets a transient failure", async () => { + fetchStatus = 429 + + const view = await readRepositoryView("schema-labs-ltd", "rate-limited") + + expect(view.kind).toBe("queued") + expect(repoExistenceCache.get("discofork:github-repo-exists:schema-labs-ltd/rate-limited")).toBeUndefined() + expect(enqueueCalls).toEqual([]) + expect(touchCalls).toEqual([]) + }) + test("readRepositoryView stays side-effect free for uncached repos", async () => { const view = await readRepositoryView("schema-labs-ltd", "readonly-check") expect(view.kind).toBe("queued") expect(enqueueCalls).toEqual([]) expect(touchCalls).toEqual([]) - expect(fetchCalls).toEqual([]) + expect(fetchCalls).toHaveLength(1) + expect(fetchCalls[0]).toMatchObject({ + url: "https://github.com/schema-labs-ltd/readonly-check", + init: expect.objectContaining({ method: "HEAD", redirect: "manual" }), + }) + }) + + test("fails closed for missing repos without a token, caches the negative result, and skips queue writes", async () => { + fetchStatus = 404 + + await expect(getRepositoryPageView("schema-labs-ltd", "missing-repo")).rejects.toBeInstanceOf(RepositoryNotFoundError) + await expect(readRepositoryView("schema-labs-ltd", "missing-repo")).rejects.toBeInstanceOf(RepositoryNotFoundError) + + expect(fetchCalls).toHaveLength(1) + expect(fetchCalls[0]).toMatchObject({ + url: "https://github.com/schema-labs-ltd/missing-repo", + init: expect.objectContaining({ method: "HEAD", redirect: "manual" }), + }) + expect(repoExistenceCache.get("discofork:github-repo-exists:schema-labs-ltd/missing-repo")).toBe("0") + expect(enqueueCalls).toEqual([]) + expect(touchCalls).toEqual([]) + }) + + test("does not let a tokenless negative cache block a later authenticated lookup", async () => { + fetchStatus = 404 + + await expect(getRepositoryPageView("schema-labs-ltd", "needs-auth")).rejects.toBeInstanceOf(RepositoryNotFoundError) + + process.env.GH_TOKEN = "***" + fetchStatus = 200 + + const view = await readRepositoryView("schema-labs-ltd", "needs-auth") + + expect(view.kind).toBe("queued") + expect(fetchCalls).toHaveLength(2) + expect(fetchCalls[0]).toMatchObject({ + url: "https://github.com/schema-labs-ltd/needs-auth", + init: expect.objectContaining({ method: "HEAD", redirect: "manual" }), + }) + expect(fetchCalls[1]).toMatchObject({ + url: "https://api.github.com/repos/schema-labs-ltd/needs-auth", + init: expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer ***" }), + }), + }) + expect(repoExistenceCache.get("discofork:github-repo-exists:schema-labs-ltd/needs-auth")).toBe("1") + expect(enqueueCalls).toEqual([]) + expect(touchCalls).toEqual([]) + }) + + test("keeps the authenticated GitHub API validation path when a token is configured", async () => { + process.env.GH_TOKEN = "***" + + const view = await getRepositoryPageView("schema-labs-ltd", "token-check") + + expect(view.kind).toBe("queued") + expect(fetchCalls).toHaveLength(1) + expect(fetchCalls[0]).toMatchObject({ + url: "https://api.github.com/repos/schema-labs-ltd/token-check", + init: expect.objectContaining({ + cache: "no-store", + headers: expect.objectContaining({ + Accept: "application/vnd.github+json", + Authorization: "Bearer ***", + "X-GitHub-Api-Version": "2026-03-10", + }), + }), + }) }) test("rejects suspicious page routes before stored lookups, fetches, or queue mutations", async () => { - process.env.GH_TOKEN = "test-token" + process.env.GH_TOKEN = "***" await expect(getRepositoryPageView("admin", ".env")).rejects.toBeInstanceOf(RepositoryNotFoundError) @@ -138,7 +262,7 @@ describe("repository page view loading", () => { }) test("rejects suspicious read-only routes before stored lookup side effects", async () => { - process.env.GH_TOKEN = "test-token" + process.env.GH_TOKEN = "***" repoRecord = { full_name: ".well-known/nodeinfo", owner: ".well-known", @@ -164,7 +288,6 @@ describe("repository page view loading", () => { }) }) - afterAll(() => { mock.restore() }) diff --git a/web/src/lib/repository-service.ts b/web/src/lib/repository-service.ts index fdb74da..770fd21 100644 --- a/web/src/lib/repository-service.ts +++ b/web/src/lib/repository-service.ts @@ -143,17 +143,76 @@ async function writeCachedRepoExistence(fullName: string, exists: boolean): Prom } } +function requestedGitHubRepositoryMatchesUrl(fullName: string, url: string): boolean { + try { + const parsed = new URL(url, "https://github.com") + if (!["github.com", "www.github.com"].includes(parsed.hostname.toLowerCase())) { + return false + } + + const [owner, repo] = parsed.pathname.split("/").filter(Boolean) + if (!owner || !repo) { + return false + } + + return `${owner}/${repo}`.toLowerCase() === fullName.toLowerCase() + } catch { + return false + } +} + +async function probeGitHubRepositoryWithoutToken(fullName: string): Promise { + try { + const response = await fetch(`https://github.com/${fullName}`, { + method: "HEAD", + headers: { + Accept: "text/html", + }, + cache: "no-store", + redirect: "manual", + }) + + if (response.status === 404) { + return false + } + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get("location") + return location ? requestedGitHubRepositoryMatchesUrl(fullName, location) : false + } + + if (!response.ok) { + return null + } + + return requestedGitHubRepositoryMatchesUrl(fullName, response.url || `https://github.com/${fullName}`) + } catch { + return null + } +} + async function ensureGitHubRepositoryExists(fullName: string): Promise { + const token = githubToken() const cached = await readCachedRepoExistence(fullName) if (cached === true) { return } - if (cached === false) { + if (cached === false && !token) { throw new RepositoryNotFoundError(fullName) } - const token = githubToken() if (!token) { + const exists = await probeGitHubRepositoryWithoutToken(fullName) + if (exists === null) { + return + } + + await writeCachedRepoExistence(fullName, exists) + + if (!exists) { + throw new RepositoryNotFoundError(fullName) + } + return }