From e49159f65eaad36872c2d25a40112ed199b5f17b Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 6 Oct 2026 15:32:39 +0300 Subject: [PATCH] test(oauth): check OAuth scopes against a live server Add `pnpm test:scopes`, which signs in and refreshes the token through the extension's OAuth flow against a throwaway deployment (test/scopes/compose.yaml). It checks that login and refresh both grant exactly the scopes the extension requests from that server, then runs a probe for every Coder API method the extension calls and for the requests `coder ssh` and `coder start` make, using the refreshed token. `scopeProbes.test.ts` runs in `pnpm test` and uses the TypeScript checker to find every CoderApi method src/ references, failing when one has no probe or listed reason. The workflow runs the suite on relevant PRs against the v2.38.0 release, the first that enforces requested scopes, and nightly against `coder-preview:latest`. A failed nightly run comments on an open tracking issue, or opens one. --- .github/workflows/oauth-scopes.yaml | 57 ++++ AGENTS.md | 2 + CONTRIBUTING.md | 4 +- docs/TESTING.md | 21 ++ package.json | 3 +- pnpm-workspace.yaml | 3 +- scripts/test-electron.sh | 2 +- src/websocket/oneWayWebSocket.ts | 2 +- test/scopes/compose.yaml | 28 ++ test/scopes/deployment.ts | 385 ++++++++++++++++++++++++++++ test/scopes/oauthScopes.test.ts | 58 +++++ test/scopes/probes.ts | 304 ++++++++++++++++++++++ test/unit/oauth/scopeProbes.test.ts | 141 ++++++++++ vitest.config.mts | 27 +- 14 files changed, 1022 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/oauth-scopes.yaml create mode 100644 test/scopes/compose.yaml create mode 100644 test/scopes/deployment.ts create mode 100644 test/scopes/oauthScopes.test.ts create mode 100644 test/scopes/probes.ts create mode 100644 test/unit/oauth/scopeProbes.test.ts diff --git a/.github/workflows/oauth-scopes.yaml b/.github/workflows/oauth-scopes.yaml new file mode 100644 index 0000000000..bf0bdd77e9 --- /dev/null +++ b/.github/workflows/oauth-scopes.yaml @@ -0,0 +1,57 @@ +name: OAuth Scopes + +# PRs use the release pinned in compose.yaml; nightly runs use the latest +# preview to catch server changes before they ship. +on: + pull_request: + # Code the probes run or import + paths: + - src/** + - test/mocks/** + - test/scopes/** + - package.json + - pnpm-lock.yaml + - vitest.config.mts + - .github/workflows/oauth-scopes.yaml + schedule: + # Off the hour, when GitHub delays or drops fewer scheduled runs + - cron: "17 6 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + live-server: + name: Live Server Test (coder-preview) + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup + - run: docker compose -f test/scopes/compose.yaml up -d --wait + env: + CODER_IMAGE: ${{ github.event_name == 'schedule' && 'ghcr.io/coder/coder-preview:latest' || '' }} + - run: pnpm test:scopes + env: + CODER_SCOPES_TEST_URL: http://localhost:7080 + # A failed scheduled run notifies only one person, so track it in an issue. + - name: Report nightly failure + if: failure() && github.event_name == 'schedule' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + title="Nightly OAuth scope test failed" + issue=$(gh issue list --state open --search "\"$title\" in:title" --json number --jq '.[0].number') + if [ -n "$issue" ]; then + gh issue comment "$issue" --body "Failed again: $RUN_URL" + else + gh issue create --title "$title" --body "The scope test failed against \`coder-preview:latest\`: $RUN_URL" + fi diff --git a/AGENTS.md b/AGENTS.md index a5c895f455..4636ca06d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,8 @@ Before changing anything under `packages/` or `test/webview/`, read - Unit tests: `pnpm test`, or `pnpm test:extension` / `pnpm test:webview` with an optional file path - Integration tests: `pnpm test:integration` +- OAuth scope tests: `pnpm test:scopes`, against a live server + ([docs/TESTING.md](docs/TESTING.md#oauth-scope-tests)) - Storybook: `pnpm storybook`; theme snapshots: `pnpm sync:vscode-themes` Integration tests and the theme sync launch VS Code; on headless machines diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e5adbd2f15..e3e728fcb6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,8 +8,8 @@ guides cover the rest; they serve humans and coding agents alike, and naming, and test placement. - [Architecture](docs/ARCHITECTURE.md): how opening a workspace works, SSH config management, Windows permissions and connection logging. -- [Testing](docs/TESTING.md): unit, webview and integration tests, and - testing the open flow by hand. +- [Testing](docs/TESTING.md): unit, webview, integration and OAuth scope + tests, and testing the open flow by hand. - [Tooling](docs/TOOLING.md): linting, formatting, TypeScript, Node.js and dependency upgrades. - [Webviews](packages/AGENTS.md): React, `@repo/ui`, Storybook and the diff --git a/docs/TESTING.md b/docs/TESTING.md index 2d11f1bf19..d9b673d123 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -50,6 +50,7 @@ test/ ├── unit/ # Extension unit tests (mirrors src/) ├── webview/ # Webview unit tests (mirrors packages//src/, jsdom) ├── integration/ # Integration tests (real VS Code, Mocha) +├── scopes/ # OAuth scopes against a live server ├── utils/ # Test utilities that are also tested └── mocks/ # Shared test mocks ``` @@ -72,6 +73,26 @@ command with `xvfb-run -a`. run in that copy with its own user data directory, so an open VS Code doesn't interfere. +## OAuth scope tests + +`test/scopes/` signs in and refreshes the token through the extension's OAuth +flow against a live server, then probes each server call the extension makes. +When `pnpm test` reports an unprobed method, add a probe to +`test/scopes/probes.ts` and run the suite. It needs Docker and a fresh +deployment, since it creates users and workspaces, and `pnpm test` leaves it +out: + +```bash +docker compose -f test/scopes/compose.yaml up -d --wait +CODER_SCOPES_TEST_URL=http://localhost:7080 pnpm test:scopes +docker compose -f test/scopes/compose.yaml down -v # before the next run +``` + +Pull requests run against the Coder release pinned in `compose.yaml`, which +must enforce requested scopes, as 2.38 does and 2.37 doesn't. The +nightly run uses `coder-preview:latest` and reports failures in a "Nightly +OAuth scope test failed" issue. Set `CODER_IMAGE` to test another image. + ## Testing the open flow by hand There are a few ways you can test the "Open in VS Code" flow: diff --git a/package.json b/package.json index 4eaf7ed8cf..a844cd5de4 100644 --- a/package.json +++ b/package.json @@ -41,9 +41,10 @@ "storybook:build": "storybook build --config-dir .storybook", "storybook:ci": "storybook build --test --config-dir .storybook", "sync:vscode-themes": "node .storybook/themes/sync.mjs", - "test": "cross-env CI=true ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs", + "test": "cross-env CI=true ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project !scopes", "test:extension": "cross-env ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project extension", "test:integration": "pnpm build:test && node scripts/esbuild.mjs && vscode-test --config test/integration/.vscode-test.mjs", + "test:scopes": "cross-env ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project scopes", "test:webview": "cross-env ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project webview", "typecheck": "concurrently -g -n extension,tests,packages,storybook \"tsc --noEmit\" \"tsc --noEmit -p test\" \"pnpm typecheck:packages\" \"tsc --noEmit -p .storybook\"", "typecheck:packages": "pnpm -r --filter \"./packages/*\" --parallel typecheck", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a9ece3e060..006c05f8c6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,7 +22,8 @@ catalog: react-dom: ^19.3.0 storybook: ^10.6.1 storybook-addon-pseudo-states: ^10.6.1 - # typescript stays on the 6.0 API for typescript-eslint; TypeScript 7 runs `tsc`. + # typescript stays on the 6.0 API for typescript-eslint and the compiler API in + # test/unit/oauth/scopeProbes.test.ts; TypeScript 7 runs `tsc`. # https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/ typescript: npm:@typescript/typescript6@^6.0.2 vite: ^8.3.2 diff --git a/scripts/test-electron.sh b/scripts/test-electron.sh index 08413f4ab1..8d8921bc38 100755 --- a/scripts/test-electron.sh +++ b/scripts/test-electron.sh @@ -30,4 +30,4 @@ pnpm --dir "$RUNNER_DIR" add "electron@$ELECTRON_VERSION" echo "Running tests with Electron $ELECTRON_VERSION..." ELECTRON_RUN_AS_NODE=1 \ - "$RUNNER_DIR/node_modules/.bin/electron" node_modules/vitest/vitest.mjs + "$RUNNER_DIR/node_modules/.bin/electron" node_modules/vitest/vitest.mjs --project "!scopes" diff --git a/src/websocket/oneWayWebSocket.ts b/src/websocket/oneWayWebSocket.ts index 243f51df83..344019fe18 100644 --- a/src/websocket/oneWayWebSocket.ts +++ b/src/websocket/oneWayWebSocket.ts @@ -161,7 +161,7 @@ export class OneWayWebSocket< } } -function rawDataToString(data: RawData): string { +export function rawDataToString(data: RawData): string { if (Buffer.isBuffer(data)) { return data.toString("utf8"); } else if (data instanceof ArrayBuffer) { diff --git a/test/scopes/compose.yaml b/test/scopes/compose.yaml new file mode 100644 index 0000000000..51e1fcdfca --- /dev/null +++ b/test/scopes/compose.yaml @@ -0,0 +1,28 @@ +# A throwaway deployment for `pnpm test:scopes`; see docs/TESTING.md. +services: + postgres: + image: postgres:17 + environment: + POSTGRES_USER: coder + POSTGRES_PASSWORD: coder + POSTGRES_DB: coder + healthcheck: + test: pg_isready -U coder + interval: 2s + retries: 30 + + coder: + image: ${CODER_IMAGE:-ghcr.io/coder/coder:v2.38.0@sha256:38a4cfc548b096484d6d1743e5f1a5af18301ed9a7ffde5150663822f9887026} + ports: ["127.0.0.1:7080:7080"] + environment: + CODER_PG_CONNECTION_URL: postgres://coder:coder@postgres:5432/coder?sslmode=disable + CODER_HTTP_ADDRESS: 0.0.0.0:7080 + CODER_ACCESS_URL: http://localhost:7080 + CODER_OAUTH2_PROVIDER_ENABLE: "true" + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: curl -fs http://localhost:7080/healthz + interval: 2s + retries: 60 diff --git a/test/scopes/deployment.ts b/test/scopes/deployment.ts new file mode 100644 index 0000000000..c1ec6839df --- /dev/null +++ b/test/scopes/deployment.ts @@ -0,0 +1,385 @@ +import { isAxiosError } from "axios"; +import { Api } from "coder/site/src/api/api"; +import { once } from "node:events"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { vi } from "vitest"; +import * as vscode from "vscode"; +import WebSocket from "ws"; + +import { fullName } from "@/cli/cliBinary"; +import { toSafeHost } from "@/common/url"; +import { OAuthAuthorizer } from "@/oauth/authorizer"; +import { OAuthCallback } from "@/oauth/oauthCallback"; +import { OAuthSessionManager } from "@/oauth/sessionManager"; +import { buildOAuthTokenData } from "@/oauth/tokens"; +import { MementoManager } from "@/storage/mementoManager"; +import { SecretsManager } from "@/storage/secretsManager"; + +import { + createMockLogger, + createMockServiceContainer, + InMemoryMemento, + InMemorySecretStorage, + MockCancellationToken, + MockConfigurationProvider, + MockProgress, +} from "../mocks/testHelpers"; + +import type { + OAuth2TokenResponse, + ProvisionerJob, + Template, + Workspace, + WorkspaceBuild, +} from "coder/site/src/api/typesGenerated"; + +export interface Deployment { + url: string; + /** The member's OAuth access token, refreshed once like a long session's. */ + token: string; + /** Scopes the server granted at login and at refresh. */ + grantedScopes: { login: string; refresh: string }; + /** Signed in with `token`. */ + scoped: Api; + /** The deployment owner, for setup and moving workspaces between states. */ + admin: Api; + memberId: string; + /** Owned by the member, with automatic updates on. */ + own: Workspace; + /** Owned by the admin and shared with the member. */ + shared: Workspace; + /** CLI binary the deployment serves for this platform. */ + cliPath: string; +} + +const PASSWORD = "SomeLongPassw0rd!"; + +const TAR_BLOCK_SIZE = 512; + +/** + * Needs no infrastructure or modules. `terraform_data` uses the agent token so + * start builds list the agent among their resources. + */ +const TEMPLATE = ` +terraform { + required_providers { + coder = { source = "coder/coder" } + } +} +data "coder_workspace" "me" {} +data "coder_parameter" "size" { + name = "size" + type = "string" + default = "small" + mutable = true +} +data "coder_workspace_preset" "small" { + name = "small" + parameters = { size = "small" } +} +resource "coder_agent" "dev" { + os = "linux" + arch = "amd64" +} +resource "terraform_data" "dev" { + count = data.coder_workspace.me.start_count + input = coder_agent.dev.token +} +`; + +/** + * Creates users, a template, two running workspaces and the member's OAuth + * token. Refuses servers with users but takes over new ones: use a throwaway. + */ +export async function setUpDeployment(url: string): Promise { + // Default settings for the extension code that setup and the probes run. + new MockConfigurationProvider(); + const admin = createClient(url); + if (await admin.hasFirstUser()) { + throw new Error( + `${url} already has users. Reset it with \`docker compose -f test/scopes/compose.yaml down -v\`, then \`up -d --wait\`.`, + ); + } + const email = "admin@example.com"; + await admin.getAxiosInstance().post("/api/v2/users/first", { + email, + username: "admin", + password: PASSWORD, + trial: false, + }); + await signIn(admin, email); + // The extension registers itself, and DCR is off by default. + await admin.putOAuth2ProviderSettings({ + dynamic_client_registration_enabled: true, + }); + const [org] = (await admin.getAuthenticatedUser()).organization_ids; + const templateId = await createTemplate(admin, org); + + const memberUser = await admin.createUser({ + email: "member@example.com", + username: "member", + name: "", + password: PASSWORD, + login_type: "password", + user_status: null, + organization_ids: [org], + }); + const member = await signIn(createClient(url), memberUser.email); + const [own, shared, cliPath] = await Promise.all([ + createRunningWorkspace(member, "own", templateId), + createRunningWorkspace(admin, "shared", templateId), + downloadCli(admin), + ]); + await admin.updateWorkspaceACL(shared.id, { + user_roles: { [memberUser.id]: "admin" }, + }); + await member.updateWorkspaceAutomaticUpdates(own.id, "always"); + + const { login, refresh } = await signInWithOAuth(url, member); + const token = refresh.access_token; + return { + url, + token, + grantedScopes: { login: login.scope ?? "", refresh: refresh.scope ?? "" }, + scoped: createClient(url, token), + admin, + memberId: memberUser.id, + own, + shared, + cliPath, + }; +} + +/** Promotes a new template version, outdating every workspace on the template. */ +export async function promoteNewTemplateVersion( + admin: Api, + { organization_id, template_id }: Workspace, +): Promise { + const id = await createTemplateVersion(admin, organization_id, template_id); + await admin.updateActiveTemplateVersion(template_id, { id }); +} + +/** Creates a workspace for the signed-in user and returns it, with its agents, once built. */ +async function createRunningWorkspace( + api: Api, + name: string, + templateId: string, +): Promise { + const workspace = await api.createWorkspace("me", { + name, + template_id: templateId, + }); + await awaitBuildSuccess(api, workspace.latest_build); + return api.getWorkspace(workspace.id); +} + +export function awaitBuildSuccess( + api: Api, + build: WorkspaceBuild, +): Promise { + return awaitJobSuccess(api, `/api/v2/workspacebuilds/${build.id}`); +} + +/** Waits for `parent` and the build queued after it; fails unless both succeed. */ +export async function awaitFollowUpBuild( + api: Api, + parent: WorkspaceBuild, +): Promise { + await awaitBuildSuccess(api, parent); + const next = await vi.waitFor( + async () => { + const { latest_build } = await api.getWorkspace(parent.workspace_id); + if (latest_build.build_number <= parent.build_number) { + throw new Error(`No build followed build ${parent.build_number}`); + } + return latest_build; + }, + { timeout: 60_000, interval: 1_000 }, + ); + await awaitBuildSuccess(api, next); +} + +/** + * Waits for the job's log stream to close, which the server does once the job + * completes, even if it completed before the stream opened. Throws unless the + * job succeeded. + */ +async function awaitJobSuccess(api: Api, path: string): Promise { + await once(openSocket(api, `${path}/logs?follow=true`), "close"); + const { data } = await api + .getAxiosInstance() + .get<{ job: ProvisionerJob }>(path); + if (data.job.status !== "succeeded") { + throw new Error(`${path}: job ${data.job.status}: ${data.job.error ?? ""}`); + } +} + +export function openSocket(api: Api, path: string): WebSocket { + const url = new URL(path, api.getAxiosInstance().defaults.baseURL); + url.protocol = url.protocol.replace("http", "ws"); + return new WebSocket(url, { + headers: { "Coder-Session-Token": api.getSessionToken() ?? "" }, + }); +} + +function createClient(url: string, token?: string): Api { + const api = new Api(); + api.setHost(url); + if (token) { + api.setSessionToken(token); + } + api + .getAxiosInstance() + .interceptors.response.use(undefined, (error: unknown) => { + if ( + isAxiosError<{ message?: string; detail?: string }>(error) && + error.response + ) { + const { config, response } = error; + const reason = [response.data?.message, response.data?.detail] + .filter(Boolean) + .join(": "); + error.message = `${config?.method?.toUpperCase()} ${config?.url}: ${response.status} ${reason}`; + } + throw error; + }); + return api; +} + +async function signIn(api: Api, email: string): Promise { + const { session_token } = await api.login(email, PASSWORD); + api.setSessionToken(session_token); + return api; +} + +/** + * Runs the extension's OAuth login, consenting as `member` in place of the + * browser, then its token refresh. + */ +async function signInWithOAuth( + url: string, + member: Api, +): Promise<{ login: OAuth2TokenResponse; refresh: OAuth2TokenResponse }> { + const secrets = new InMemorySecretStorage(); + const logger = createMockLogger(); + const callback = new OAuthCallback(secrets, logger); + const secretsManager = new SecretsManager( + secrets, + new MementoManager(new InMemoryMemento()), + logger, + ); + const authorizer = new OAuthAuthorizer( + secretsManager, + callback, + logger, + "coder.coder-remote", + ); + vi.mocked(vscode.env.openExternal).mockImplementation(async (uri) => { + const consent = await member + .getAxiosInstance() + .post(uri.toString(), undefined, { + maxRedirects: 0, + validateStatus: (status) => status === 302, + }); + const { searchParams } = new URL(String(consent.headers.location)); + const error = searchParams.get("error"); + await callback.send({ + state: searchParams.get("state") ?? "", + code: searchParams.get("code"), + error: + error && `${error}: ${searchParams.get("error_description") ?? ""}`, + }); + return true; + }); + const deployment = { url, safeHostname: toSafeHost(url) }; + let login: OAuth2TokenResponse; + try { + ({ tokenResponse: login } = await authorizer.login( + deployment, + new MockProgress(), + new MockCancellationToken(), + )); + } finally { + authorizer.dispose(); + } + await secretsManager.setSessionAuth(deployment.safeHostname, { + url, + token: login.access_token, + oauth: buildOAuthTokenData(login), + }); + const sessions = OAuthSessionManager.create( + deployment, + createMockServiceContainer({ secretsManager, logger }), + ); + try { + return { login, refresh: await sessions.refreshToken() }; + } finally { + sessions.dispose(); + } +} + +async function createTemplate(admin: Api, org: string): Promise { + const versionId = await createTemplateVersion(admin, org); + const { data: template } = await admin + .getAxiosInstance() + .post