Skip to content

Commit 36cea00

Browse files
committed
test(oauth): check OAuth scopes against a live server
Calls every Coder API method and stream the extension uses with a DEFAULT_OAUTH_SCOPES token against coder-preview, on changes to the scope or probe list and nightly. A unit test finds those methods in src/ with the type checker and fails when one has no probe. test/scopes/compose.yaml runs the same deployment locally.
1 parent 7a718cd commit 36cea00

10 files changed

Lines changed: 806 additions & 9 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: OAuth Scopes
2+
3+
# Runs the probes in test/scopes against a live server. The nightly run tracks
4+
# coder/coder main, so a server-side scope change fails here before it ships.
5+
on:
6+
push:
7+
branches: [main]
8+
paths: &paths
9+
- src/oauth/constants.ts
10+
- test/scopes/**
11+
- .github/workflows/oauth-scopes.yaml
12+
pull_request:
13+
paths: *paths
14+
schedule:
15+
- cron: "0 6 * * *"
16+
workflow_dispatch:
17+
18+
permissions:
19+
contents: read
20+
21+
jobs:
22+
live-server:
23+
name: Live Server Test (coder-preview)
24+
runs-on: ubuntu-24.04
25+
timeout-minutes: 15
26+
steps:
27+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
28+
with:
29+
persist-credentials: false
30+
- uses: ./.github/actions/setup
31+
- run: docker compose -f test/scopes/compose.yaml up -d --wait
32+
- run: pnpm test:scopes
33+
env:
34+
CODER_URL: http://localhost:7080

‎AGENTS.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ When editing `.oxlintrc.jsonc`:
7575
- Descriptive names, minimal setup, no shared mutable state
7676
- Never mock in end-to-end tests; minimize mocking in unit tests
7777
- Find root causes, not symptoms - read error messages carefully
78+
- When the extension calls a new Coder API method, add a probe to
79+
`test/scopes/probes.ts`; see
80+
[CONTRIBUTING.md](CONTRIBUTING.md#oauth-scope-tests) to run them
7881
- When mocking constructors (classes) with
7982
`vi.mocked(...).mockImplementation()`, use regular functions, not arrow
8083
functions. Arrow functions can't be called with `new`.

‎CONTRIBUTING.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,22 @@ pnpm test:integration
242242
- Requires closing VS Code or running in a clean environment
243243
- Test files in `test/integration/` are compiled to `out/` before running
244244

245+
### OAuth Scope Tests
246+
247+
`test/scopes/` calls every Coder API method the extension uses with a token
248+
limited to `DEFAULT_OAUTH_SCOPES`, against a live server. It needs Docker and a
249+
fresh deployment, since it creates users and workspaces:
250+
251+
```bash
252+
docker compose -f test/scopes/compose.yaml up -d --wait
253+
CODER_URL=http://localhost:7080 pnpm test:scopes
254+
docker compose -f test/scopes/compose.yaml down -v # before the next run
255+
```
256+
257+
When the extension calls a new Coder API method, add a probe to
258+
`test/scopes/probes.ts`. `test/unit/oauth/scopeProbes.test.ts` fails until you
259+
do.
260+
245261
## Development
246262

247263
> [!IMPORTANT]

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"test": "cross-env CI=true ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs",
3838
"test:extension": "cross-env ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project extension",
3939
"test:integration": "pnpm build:test && node esbuild.mjs && vscode-test",
40+
"test:scopes": "cross-env ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project scopes",
4041
"test:webview": "cross-env ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project webview",
4142
"typecheck": "concurrently -g -n extension,tests,packages,storybook \"tsc --noEmit\" \"tsc --noEmit -p test\" \"pnpm typecheck:packages\" \"tsc --noEmit -p .storybook\"",
4243
"typecheck:packages": "pnpm -r --filter \"./packages/*\" --parallel typecheck",

‎test/scopes/compose.yaml‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# A throwaway Coder deployment for `pnpm test:scopes`, used locally and in CI.
2+
# docker compose -f test/scopes/compose.yaml up -d --wait
3+
# CODER_URL=http://localhost:7080 pnpm test:scopes
4+
# docker compose -f test/scopes/compose.yaml down -v
5+
services:
6+
postgres:
7+
image: postgres:17
8+
environment:
9+
POSTGRES_USER: coder
10+
POSTGRES_PASSWORD: coder
11+
POSTGRES_DB: coder
12+
healthcheck:
13+
test: pg_isready -U coder
14+
interval: 2s
15+
retries: 30
16+
17+
coder:
18+
image: ghcr.io/coder/coder-preview:latest
19+
ports: ["7080:7080"]
20+
environment:
21+
CODER_PG_CONNECTION_URL: postgres://coder:coder@postgres:5432/coder?sslmode=disable
22+
CODER_HTTP_ADDRESS: 0.0.0.0:7080
23+
CODER_ACCESS_URL: http://localhost:7080
24+
depends_on:
25+
postgres:
26+
condition: service_healthy
27+
healthcheck:
28+
test: curl -fs http://localhost:7080/healthz
29+
interval: 2s
30+
retries: 60

‎test/scopes/deployment.ts‎

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import { isAxiosError } from "axios";
2+
import { Api } from "coder/site/src/api/api";
3+
import { once } from "node:events";
4+
import WebSocket from "ws";
5+
6+
import type {
7+
APIKeyScope,
8+
ProvisionerJob,
9+
Template,
10+
User,
11+
Workspace,
12+
WorkspaceBuild,
13+
} from "coder/site/src/api/typesGenerated";
14+
15+
export interface Deployment {
16+
url: string;
17+
/** The member's token, limited to the scopes under test. */
18+
token: string;
19+
/** Signed in with `token`. */
20+
scoped: Api;
21+
/** The deployment owner, used for setup and to move workspaces between states. */
22+
admin: Api;
23+
/** Owned by the member. */
24+
own: Workspace;
25+
/** Owned by the admin and shared with the member. */
26+
shared: Workspace;
27+
}
28+
29+
/** Password for every user this module creates. */
30+
const PASSWORD = "SomeLongPassw0rd!";
31+
32+
/** Uses only the coder provider, so builds need no infrastructure or modules. */
33+
const TEMPLATE = `
34+
terraform {
35+
required_providers {
36+
coder = { source = "coder/coder" }
37+
}
38+
}
39+
data "coder_workspace" "me" {}
40+
data "coder_parameter" "size" {
41+
name = "size"
42+
type = "string"
43+
default = "small"
44+
mutable = true
45+
}
46+
data "coder_workspace_preset" "small" {
47+
name = "small"
48+
parameters = { size = "small" }
49+
}
50+
resource "coder_agent" "dev" {
51+
os = "linux"
52+
arch = "amd64"
53+
}
54+
resource "terraform_data" "dev" {
55+
count = data.coder_workspace.me.start_count
56+
input = coder_agent.dev.token
57+
}
58+
`;
59+
60+
/**
61+
* Creates users, a template and two running workspaces on a fresh deployment.
62+
* Refuses one that already has users, so it never writes to a real server.
63+
*/
64+
export async function setUpDeployment(
65+
url: string,
66+
scopes: string,
67+
): Promise<Deployment> {
68+
const admin = createClient(url);
69+
if (await admin.hasFirstUser()) {
70+
throw new Error(`${url} already has users; use a fresh deployment`);
71+
}
72+
const org = await createFirstUser(admin);
73+
const templateId = await createTemplate(admin, org);
74+
75+
const member = createClient(url);
76+
const memberUser = await createMember(admin, member, org);
77+
const [own, shared] = await Promise.all([
78+
createRunningWorkspace(member, "me", "own", templateId),
79+
createRunningWorkspace(admin, "me", "shared", templateId),
80+
]);
81+
await admin.updateWorkspaceACL(shared.id, {
82+
user_roles: { [memberUser.id]: "admin" },
83+
});
84+
85+
const { key: token } = await member.createToken({
86+
token_name: "scopes",
87+
lifetime: 0, // the deployment's default
88+
scopes: scopes.split(" ") as APIKeyScope[],
89+
});
90+
const scoped = createClient(url);
91+
scoped.setSessionToken(token);
92+
93+
return { url, token, scoped, admin, own, shared };
94+
}
95+
96+
/**
97+
* Creates a workspace for `owner` and returns it once its first build
98+
* succeeds, with the resources and agents that build created.
99+
*/
100+
export async function createRunningWorkspace(
101+
api: Api,
102+
owner: string,
103+
name: string,
104+
templateId: string,
105+
): Promise<Workspace> {
106+
const { id, latest_build } = await api.createWorkspace(owner, {
107+
name,
108+
template_id: templateId,
109+
});
110+
await waitForBuild(api, latest_build);
111+
return api.getWorkspace(id);
112+
}
113+
114+
/** Waits for the build's job to finish and fails unless it succeeded. */
115+
export async function waitForBuild(
116+
api: Api,
117+
build: WorkspaceBuild,
118+
): Promise<void> {
119+
const path = `/api/v2/workspacebuilds/${build.id}`;
120+
await waitForJob(api, `${path}/logs`, async () => {
121+
// By ID, since a finished delete build leaves no workspace to look up.
122+
const { data } = await api.getAxiosInstance().get<WorkspaceBuild>(path);
123+
return data.job;
124+
});
125+
}
126+
127+
/**
128+
* Follows the job's log stream, which the server closes once the job
129+
* completes (even when it completed before the stream opened), then checks
130+
* the outcome.
131+
*/
132+
async function waitForJob(
133+
api: Api,
134+
logsPath: string,
135+
getJob: () => Promise<ProvisionerJob>,
136+
): Promise<void> {
137+
const url = new URL(
138+
`${logsPath}?follow=true`,
139+
api.getAxiosInstance().defaults.baseURL,
140+
);
141+
url.protocol = url.protocol.replace("http", "ws");
142+
const socket = new WebSocket(url, {
143+
headers: { "Coder-Session-Token": api.getSessionToken() ?? "" },
144+
});
145+
await once(socket, "close");
146+
const job = await getJob();
147+
if (job.status !== "succeeded") {
148+
throw new Error(`Provisioner job ${job.status}: ${job.error ?? ""}`);
149+
}
150+
}
151+
152+
/** Creates a client whose request errors include the server's message, not just the status. */
153+
function createClient(url: string): Api {
154+
const api = new Api();
155+
api.setHost(url);
156+
api
157+
.getAxiosInstance()
158+
.interceptors.response.use(undefined, (error: unknown) => {
159+
if (
160+
isAxiosError<{ message?: string; detail?: string }>(error) &&
161+
error.response
162+
) {
163+
const { config, response } = error;
164+
const reason = [response.data?.message, response.data?.detail]
165+
.filter(Boolean)
166+
.join(": ");
167+
error.message = `${config?.method?.toUpperCase()} ${config?.url}: ${response.status} ${reason}`;
168+
}
169+
throw error;
170+
});
171+
return api;
172+
}
173+
174+
async function signIn(api: Api, email: string): Promise<User> {
175+
const { session_token } = await api.login(email, PASSWORD);
176+
api.setSessionToken(session_token);
177+
return api.getAuthenticatedUser();
178+
}
179+
180+
/** Creates the owner and returns the default organization's ID. */
181+
async function createFirstUser(admin: Api): Promise<string> {
182+
const email = "admin@example.com";
183+
await admin.getAxiosInstance().post("/api/v2/users/first", {
184+
email,
185+
username: "admin",
186+
password: PASSWORD,
187+
trial: false,
188+
});
189+
const user = await signIn(admin, email);
190+
return user.organization_ids[0];
191+
}
192+
193+
async function createMember(
194+
admin: Api,
195+
member: Api,
196+
org: string,
197+
): Promise<User> {
198+
const user = await admin.createUser({
199+
email: "member@example.com",
200+
username: "member",
201+
name: "",
202+
password: PASSWORD,
203+
login_type: "password",
204+
user_status: null,
205+
organization_ids: [org],
206+
});
207+
await signIn(member, user.email);
208+
return user;
209+
}
210+
211+
async function createTemplate(admin: Api, org: string): Promise<string> {
212+
const { data: file } = await admin
213+
.getAxiosInstance()
214+
.post<{ hash: string }>("/api/v2/files", tarFile("main.tf", TEMPLATE), {
215+
headers: { "Content-Type": "application/x-tar" },
216+
});
217+
const version = await admin.createTemplateVersion(org, {
218+
storage_method: "file",
219+
file_id: file.hash,
220+
provisioner: "terraform",
221+
tags: {},
222+
});
223+
await waitForJob(
224+
admin,
225+
`/api/v2/templateversions/${version.id}/logs`,
226+
async () => (await admin.getTemplateVersion(version.id)).job,
227+
);
228+
const { data: template } = await admin
229+
.getAxiosInstance()
230+
.post<Template>(`/api/v2/organizations/${org}/templates`, {
231+
name: "scopes",
232+
template_version_id: version.id,
233+
});
234+
return template.id;
235+
}
236+
237+
/** Builds a ustar archive holding a single file. */
238+
function tarFile(name: string, content: string): Buffer {
239+
const body = Buffer.from(content);
240+
const header = Buffer.alloc(512);
241+
header.write(name, 0);
242+
header.write("0000644\0", 100); // mode
243+
header.write("0000000\0", 108); // uid
244+
header.write("0000000\0", 116); // gid
245+
header.write(`${body.length.toString(8).padStart(11, "0")}\0`, 124);
246+
header.write("00000000000\0", 136); // mtime
247+
header.write(" ", 148); // checksum placeholder
248+
header.write("0", 156); // regular file
249+
header.write("ustar\0", 257);
250+
header.write("00", 263);
251+
const checksum = header.reduce((sum, byte) => sum + byte, 0);
252+
header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148);
253+
const padding = Buffer.alloc((512 - (body.length % 512)) % 512);
254+
return Buffer.concat([header, body, padding, Buffer.alloc(1024)]);
255+
}

‎test/scopes/oauthScopes.test.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { beforeAll, describe, expect, it } from "vitest";
2+
3+
import { DEFAULT_OAUTH_SCOPES } from "@/oauth/constants";
4+
5+
import { setUpDeployment, type Deployment } from "./deployment";
6+
import { cliProbes, knownGaps, probes } from "./probes";
7+
8+
/** A fresh Coder deployment, e.g. from compose.yaml; the suite is skipped without one. */
9+
const url = process.env.CODER_URL ?? "";
10+
11+
describe.skipIf(!url)("OAuth scopes", () => {
12+
let deployment: Deployment;
13+
14+
beforeAll(async () => {
15+
deployment = await setUpDeployment(url, DEFAULT_OAUTH_SCOPES);
16+
});
17+
18+
it.each(Object.entries({ ...probes, ...cliProbes }))(
19+
"%s",
20+
async (_name, probe) => {
21+
await probe(deployment);
22+
},
23+
);
24+
25+
it.each(Object.entries(knownGaps))(
26+
"%s (known gap)",
27+
async (_name, { probe, error }) => {
28+
const passed = new Error("The gap is fixed: move this probe to `probes`");
29+
await expect(
30+
probe(deployment).then(() => Promise.reject(passed)),
31+
).rejects.toThrow(error);
32+
},
33+
);
34+
});

0 commit comments

Comments
 (0)