From 0bfe6caf3a17024007fd06fc6c82f056f897b5c1 Mon Sep 17 00:00:00 2001 From: Roman Sevast Date: Thu, 28 May 2026 19:12:40 -0700 Subject: [PATCH] fix(browser-session): resolve repo id from HTML page for private repos The browser-session strategy looked up the repository id via api.github.com, which only accepts token auth. With cookie-based credentials (the usual browser-session case) that returns 404 on private repositories, so every upload failed with "Cannot access repository. Session may have expired." When authenticating by cookie, fall back to reading the id from the repository's HTML page (the octolytics-dimension-repository_id meta tag), which the same cookie can load. Token-based callers keep using the REST API. Adds unit coverage for the fallback success path, a page without the marker, and the both-rejected auth-failure case. --- src/core/strategies/browserSession.ts | 55 ++++++++++++- .../core/strategies/browserSession.test.ts | 77 ++++++++++++++++--- 2 files changed, 121 insertions(+), 11 deletions(-) diff --git a/src/core/strategies/browserSession.ts b/src/core/strategies/browserSession.ts index 7870900..e0bf370 100644 --- a/src/core/strategies/browserSession.ts +++ b/src/core/strategies/browserSession.ts @@ -124,6 +124,13 @@ async function getRepositoryId( ); if (!response.ok) { + // The REST API only accepts token auth — a session cookie is rejected + // (404 on private repos). When authenticating by cookie, fall back to + // reading the id from the repository's HTML page, which the same cookie + // can load. + if (credentials.cookies && !credentials.token) { + return getRepositoryIdFromPage(target, credentials); + } throw new AuthenticationError( "Cannot access repository. Session may have expired.", "SESSION_EXPIRED", @@ -134,7 +141,7 @@ async function getRepositoryId( const data = (await response.json()) as { id: number }; return String(data.id); } catch (err) { - if (err instanceof AuthenticationError) { + if (err instanceof AuthenticationError || err instanceof UploadError) { throw err; } throw new UploadError( @@ -145,6 +152,52 @@ async function getRepositoryId( } } +/** + * Resolves the repository id from the repository's HTML page. + * + * The REST API (api.github.com) does not accept session-cookie auth, so a + * cookie-based browser session cannot use it to look up private repositories. + * The repo page embeds the numeric id in an `octolytics-dimension-repository_id` + * meta tag and is reachable with the same cookie used for the upload. + * + * @internal + */ +async function getRepositoryIdFromPage( + target: UploadTarget, + credentials: BrowserSessionCredentials, +): Promise { + const response = await fetch( + `https://github.com/${target.owner}/${target.repo}`, + { + headers: { + ...buildAuthHeaders(credentials), + Accept: "text/html", + }, + }, + ); + + if (!response.ok) { + throw new AuthenticationError( + "Cannot access repository. Session may have expired.", + "SESSION_EXPIRED", + { target, status: response.status }, + ); + } + + const html = await response.text(); + const repositoryId = html.match( + /octolytics-dimension-repository_id"\s+content="(\d+)"/, + )?.[1]; + if (!repositoryId) { + throw new UploadError( + "Could not determine repository id from the repository page.", + "REPO_ID_FETCH_FAILED", + { target }, + ); + } + return repositoryId; +} + /** * Gets the upload policy and CSRF token from GitHub. * diff --git a/test/unit/core/strategies/browserSession.test.ts b/test/unit/core/strategies/browserSession.test.ts index bc11081..81b3e35 100644 --- a/test/unit/core/strategies/browserSession.test.ts +++ b/test/unit/core/strategies/browserSession.test.ts @@ -58,34 +58,91 @@ describe("Browser Session Strategy", () => { }); describe("upload - getRepositoryId", () => { - it("throws AuthenticationError on 401 response from repo API", async () => { + it("throws AuthenticationError when both repo API and page reject (401)", async () => { const strategy = createBrowserSessionStrategy("test-cookie"); const mockFilePath = "/tmp/test.png"; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: false, - status: 401, - }); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: false, status: 401 }) // api.github.com + .mockResolvedValueOnce({ ok: false, status: 401 }); // HTML page fallback await expect(strategy.upload(mockFilePath, mockTarget)).rejects.toThrow( AuthenticationError, ); }); - it("throws AuthenticationError on 403 response from repo API", async () => { + it("throws AuthenticationError when both repo API and page reject (403)", async () => { const strategy = createBrowserSessionStrategy("test-cookie"); const mockFilePath = "/tmp/test.png"; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: false, - status: 403, - }); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: false, status: 403 }) + .mockResolvedValueOnce({ ok: false, status: 403 }); await expect(strategy.upload(mockFilePath, mockTarget)).rejects.toThrow( AuthenticationError, ); }); + it("falls back to the repo HTML page for the id when the API rejects a cookie (private repo)", async () => { + const strategy = createBrowserSessionStrategy("test-cookie"); + const mockFilePath = "/tmp/test.png"; + + (global.fetch as ReturnType) + // api.github.com rejects the cookie on a private repo + .mockResolvedValueOnce({ ok: false, status: 404 }) + // the HTML page loads with the cookie and embeds the repository id + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => + '', + }) + // upload policy + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + upload_url: "https://s3.example.com/upload", + form: { key: "value" }, + token: "csrf-token-123", + }), + }) + // S3 upload + .mockResolvedValueOnce({ ok: true, status: 200 }) + // confirm + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + url: "https://github.com/user-attachments/assets/abc", + }), + }); + + const result = await strategy.upload(mockFilePath, mockTarget); + expect(result.url).toBe("https://github.com/user-attachments/assets/abc"); + expect(result.strategy).toBe("browser-session"); + }); + + it("throws UploadError when the repo page lacks a repository id", async () => { + const strategy = createBrowserSessionStrategy("test-cookie"); + const mockFilePath = "/tmp/test.png"; + + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: false, status: 404 }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => "no marker here", + }); + + const error = await strategy + .upload(mockFilePath, mockTarget) + .catch((e) => e); + expect(error).toBeInstanceOf(UploadError); + expect(error.code).toBe("REPO_ID_FETCH_FAILED"); + }); + it("throws UploadError when network error occurs during repo fetch", async () => { const strategy = createBrowserSessionStrategy("test-cookie"); const mockFilePath = "/tmp/test.png";