Files
kandev/apps/web/e2e/tests/integrations/github-authentication.spec.ts
T
2026-08-10 22:37:07 +01:00

298 lines
12 KiB
TypeScript

import { test, expect } from "../../fixtures/test-base";
import { expectElementsNotToIntersect } from "../../helpers/layout-assertions";
import { GitHubAuthSettingsPage } from "../../pages/github-auth-settings-page";
const settingsPath = (workspaceId: string) =>
`/settings/workspace/${workspaceId}/integrations/github`;
test.describe("GitHub workspace authentication", () => {
test("keeps actors isolated while switching workspaces and lists named CLI accounts", async ({
testPage,
apiClient,
seedData,
}, testInfo) => {
await apiClient.mockGitHubSetCLIAccounts([
{ host: "github.com", login: "alice-automation", active: false, state: "active" },
{ host: "github.com", login: "bob-cli", active: true, state: "active" },
]);
const workspaceB = await apiClient.createWorkspace("GitHub Workspace B");
await apiClient.mockGitHubSetWorkspaceConnection(seedData.workspaceId, {
source: "pat",
status: "active",
login: "alice-automation",
});
let releaseWorkspaceBStatus: () => void = () => {};
const workspaceBStatusGate = new Promise<void>((resolve) => {
releaseWorkspaceBStatus = resolve;
});
await testPage.route("**/api/v1/github/status?*", async (route) => {
const url = new URL(route.request().url());
if (url.searchParams.get("workspace_id") === workspaceB.id) {
await workspaceBStatusGate;
}
await route.continue();
});
await testPage.goto(settingsPath(seedData.workspaceId));
const automation = testPage.getByTestId("github-workspace-automation");
await expect(automation.getByText("alice-automation", { exact: true })).toBeVisible({
timeout: 15_000,
});
await expect(
automation.getByText("Personal access token", { exact: true }).first(),
).toBeVisible();
await automation.getByRole("button", { name: "Change connection" }).click();
const githubSettings = new GitHubAuthSettingsPage(testPage);
const connectionDialog = githubSettings.connectionSurface();
await githubSettings.chooseMethod("GitHub CLI account");
await connectionDialog.getByRole("combobox", { name: "GitHub CLI account" }).click();
await expect(testPage.getByRole("option", { name: "bob-cli (github.com)" })).toBeVisible();
await testPage.keyboard.press("Escape");
await testPage.keyboard.press("Escape");
await testPage.evaluate((path) => {
window.history.pushState({}, "", path);
window.dispatchEvent(new Event("kandev:navigation"));
}, settingsPath(workspaceB.id));
await expect(testPage.getByText("Checking GitHub connection...").first()).toBeVisible();
await expect(automation.getByText("alice-automation", { exact: true })).toHaveCount(0);
releaseWorkspaceBStatus();
await expect(automation.getByText("bob-cli", { exact: true })).toBeVisible({
timeout: 15_000,
});
await expect(automation.getByText("GitHub CLI", { exact: true })).toBeVisible();
await expect(automation.getByTestId("github-task-access-summary")).toContainText(
"Inherit executor Git credentials",
);
await expect(testPage.getByText("My GitHub identity", { exact: true })).toHaveCount(0);
await expect(
automation.getByText("This account also powers My GitHub and user-triggered actions.", {
exact: true,
}),
).toBeVisible();
const accountsResponse = await apiClient.rawRequest(
"GET",
`/api/v1/github/auth/gh-cli/accounts?workspace_id=${encodeURIComponent(workspaceB.id)}`,
);
expect(accountsResponse.status).toBe(200);
const accountsPayload = (await accountsResponse.json()) as {
accounts: Array<{ login: string; selected: boolean }>;
};
const statusResponse = await apiClient.rawRequest(
"GET",
`/api/v1/github/status?workspace_id=${encodeURIComponent(workspaceB.id)}`,
);
const statusPayload = await statusResponse.json();
expect(accountsPayload.accounts).toEqual(
expect.arrayContaining([expect.objectContaining({ login: "bob-cli", selected: true })]),
);
expect(statusPayload).toMatchObject({
automation: { source: "gh_cli", login: "bob-cli", github_host: "github.com" },
});
await automation.getByRole("button", { name: "Change connection" }).click();
const workspaceBDialog = testPage.getByRole("dialog", { name: "Change GitHub connection" });
await expect(workspaceBDialog.locator("#github-method-cli")).toHaveAttribute(
"data-state",
"checked",
);
await expect(
workspaceBDialog.getByRole("combobox", { name: "GitHub CLI account" }),
).toContainText("bob-cli");
await testPage.screenshot({
path: testInfo.outputPath("github-workspace-isolation-desktop.png"),
fullPage: true,
});
});
test("keeps loaded settings visible during refresh", async ({
testPage,
apiClient,
seedData,
}) => {
await apiClient.mockGitHubSetWorkspaceConnection(seedData.workspaceId, {
source: "pat",
status: "active",
login: "refresh-user",
});
let holdStatus = false;
let releaseStatus!: () => void;
const statusGate = new Promise<void>((resolve) => {
releaseStatus = resolve;
});
await testPage.route("**/api/v1/github/status?*", async (route) => {
if (holdStatus) await statusGate;
await route.continue();
});
await testPage.goto(settingsPath(seedData.workspaceId));
const automation = testPage.getByTestId("github-workspace-automation");
await expect(automation.getByText("refresh-user", { exact: true })).toBeVisible({
timeout: 15_000,
});
holdStatus = true;
const refresh = automation.getByRole("button", { name: "Refresh GitHub connection" });
await refresh.click();
await expect(automation.getByText("refresh-user", { exact: true })).toBeVisible();
await expect(testPage.getByText("Checking GitHub connection...", { exact: true })).toHaveCount(
0,
);
await expect(refresh).toBeDisabled();
await expect(refresh).toHaveAttribute("aria-busy", "true");
holdStatus = false;
releaseStatus();
await expect(refresh).toBeEnabled();
});
test("migrates a legacy connection explicitly and disconnects the replacement", async ({
testPage,
apiClient,
seedData,
}) => {
await apiClient.mockGitHubSetWorkspaceConnection(seedData.workspaceId, {
source: "legacy_shared",
status: "active",
});
await testPage.goto(settingsPath(seedData.workspaceId));
const automation = testPage.getByTestId("github-workspace-automation");
await expect(automation.getByText("Legacy shared connection", { exact: true })).toBeVisible({
timeout: 15_000,
});
await apiClient.mockGitHubSetWorkspaceConnection(seedData.workspaceId, {
source: "pat",
status: "active",
login: "workspace-user",
});
await automation.getByRole("button", { name: "Refresh GitHub connection" }).click();
await expect(automation.getByText("workspace-user", { exact: true })).toBeVisible();
await expect(
automation.getByText("Personal access token", { exact: true }).first(),
).toBeVisible();
await automation.getByRole("button", { name: "Disconnect", exact: true }).click();
await expect(automation.getByText("No automation connection", { exact: true })).toBeVisible();
});
test("shows App capabilities, personal reconnect, and suspended or revoked states", async ({
testPage,
apiClient,
seedData,
}, testInfo) => {
await apiClient.mockGitHubSetAppRegistration({
id: "registration-acme",
display_name: "Acme automation",
app_id: 4242,
});
await apiClient.mockGitHubSetWorkspaceConnection(seedData.workspaceId, {
source: "github_app_installation",
status: "active",
app_registration_id: "registration-acme",
installation_id: 42,
installation_account_login: "acme",
installation_account_type: "Organization",
capabilities: {
repository_read: true,
pull_request_write: true,
workflow_write: false,
},
});
await testPage.goto(settingsPath(seedData.workspaceId));
const automation = testPage.getByTestId("github-workspace-automation");
const personal = testPage.getByTestId("github-personal-identity");
await expect(automation.getByText("acme", { exact: true })).toBeVisible({ timeout: 15_000 });
await expect(automation.getByText("GitHub App", { exact: true }).first()).toBeVisible();
await expect(automation.getByText("Pull Request Write", { exact: true })).toHaveCount(0);
await automation.getByRole("button", { name: "View permissions" }).click();
const permissionsDialog = testPage.getByRole("dialog", { name: "GitHub App permissions" });
await expect(permissionsDialog.getByText("Pull Request Write", { exact: true })).toBeVisible();
await expect(permissionsDialog.getByText("Workflow Write", { exact: true })).toBeVisible();
await permissionsDialog.getByRole("button", { name: "Close" }).click();
await expect(personal.getByText("Not connected", { exact: true })).toBeVisible();
await expect(personal.getByRole("button", { name: /Connect identity/ })).toBeVisible();
await testPage.goto("/github");
await expect(
testPage.getByText(/Connect your personal GitHub identity to see pull requests/),
).toBeVisible({ timeout: 15_000 });
await apiClient.mockGitHubSetPersonalConnection(seedData.workspaceId, {
login: "alice-personal",
status: "active",
github_user_id: 7,
access_expires_at: "2030-01-01T00:00:00Z",
});
await testPage.goto(settingsPath(seedData.workspaceId));
await expect(personal.getByText("alice-personal", { exact: true })).toBeVisible({
timeout: 15_000,
});
await expect(personal.getByRole("button", { name: /Reconnect identity/ })).toBeVisible();
await apiClient.mockGitHubSetWorkspaceConnectionStatus(seedData.workspaceId, "suspended");
await apiClient.mockGitHubSetPersonalConnection(seedData.workspaceId, {
login: "alice-personal",
status: "revoked",
github_user_id: 7,
access_expires_at: "2026-01-01T00:00:00Z",
});
await automation.getByRole("button", { name: "Refresh GitHub connection" }).click();
await expect(automation.getByText("suspended", { exact: true })).toBeVisible();
await expect(personal.getByText("revoked", { exact: true })).toBeVisible();
await testPage.getByTestId("github-scope-mode").click();
await testPage.getByRole("option", { name: "Organizations" }).click();
const floatingSave = testPage.getByTestId("settings-floating-save");
const floatingSaveSurface = floatingSave.getByTestId("settings-floating-save-surface");
await expect(floatingSave).toBeVisible();
await expectElementsNotToIntersect(
floatingSaveSurface,
testPage.getByRole("button", { name: "Configuration Chat" }),
);
await testPage.screenshot({
path: testInfo.outputPath("github-app-reconnect-desktop.png"),
fullPage: true,
});
});
test("rejects invalid App and personal callback state", async ({ apiClient }) => {
const appResponse = await apiClient.rawRequest(
"GET",
"/api/v1/github/app/registrations/registration-test/install/callback?state=invalid&code=secret-code&installation_id=42",
undefined,
{ redirect: "manual" },
);
expect(appResponse.status).toBe(303);
expectInvalidCallbackLocation(appResponse);
const personalResponse = await apiClient.rawRequest(
"GET",
"/api/v1/github/app/registrations/registration-test/personal/callback?state=invalid&code=secret-code",
undefined,
{ redirect: "manual" },
);
expect(personalResponse.status).toBe(303);
expectInvalidCallbackLocation(personalResponse);
});
});
function expectInvalidCallbackLocation(response: Response) {
const location = response.headers.get("location");
expect(location).not.toBeNull();
const redirect = new URL(location!, "http://kandev.test");
expect(redirect.pathname).toBe("/settings/integrations/github");
expect(redirect.searchParams.get("github_result")).toBe("github_invalid_callback");
expect(redirect.searchParams.has("state")).toBe(false);
expect(redirect.searchParams.has("code")).toBe(false);
expect(location).not.toContain("state=invalid");
expect(location).not.toContain("secret-code");
expect(location).not.toMatch(/token/i);
}