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
55 changes: 54 additions & 1 deletion src/core/strategies/browserSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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(
Expand All @@ -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<string> {
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.
*
Expand Down
77 changes: 67 additions & 10 deletions test/unit/core/strategies/browserSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 401,
});
(global.fetch as ReturnType<typeof vi.fn>)
.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<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 403,
});
(global.fetch as ReturnType<typeof vi.fn>)
.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<typeof vi.fn>)
// 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 () =>
'<meta name="octolytics-dimension-repository_id" content="987654">',
})
// 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<typeof vi.fn>)
.mockResolvedValueOnce({ ok: false, status: 404 })
.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () => "<html><body>no marker here</body></html>",
});

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