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";