Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 134 additions & 11 deletions test/repository-service-page-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,18 @@ 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<string, string>()
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
let queueEnabled = true
let repoRecord: any = null
let statusSnapshot: any = null
let fetchStatus = 200
let fetchHeaders: Record<string, string> | undefined

mock.module(databaseModulePath, () => ({
databaseConfigured: () => databaseEnabled,
Expand All @@ -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,
}))

Expand Down Expand Up @@ -82,6 +91,8 @@ beforeEach(() => {
repoRecord = null
statusSnapshot = null
fetchStatus = 200
fetchHeaders = undefined
repoExistenceCache.clear()
enqueueCalls.length = 0
touchCalls.length = 0
fetchCalls.length = 0
Expand All @@ -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)
Expand All @@ -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)

Expand All @@ -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",
Expand All @@ -164,7 +288,6 @@ describe("repository page view loading", () => {
})
})


afterAll(() => {
mock.restore()
})
63 changes: 61 additions & 2 deletions web/src/lib/repository-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean | null> {
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<void> {
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
}

Expand Down