History

Require auth in custom tools git e2e

Rhys Sullivan committed 4 months ago

Commit 7c837dc1 parent f876e233 files · +114 −2Browse files

Unified · Split

3 files
  1. e2e/scenarios/custom-tools.test.ts +70 −1
  2. packages/plugins/apps/src/plugin/apps-plugin.test.ts +30 −0
  3. packages/plugins/apps/src/plugin/apps-plugin.ts +14 −1
e2e/scenarios/custom-tools.test.ts+70−1
@@ -12,6 +12,8 @@ import type { Identity, Target as TargetShape } from "../src/target"; · 11 unmodified lines
12import type { BrowserSurface } from "../src/surfaces/browser";12import type { BrowserSurface } from "../src/surfaces/browser";
13 13
14const textEncoder = new TextEncoder();14const textEncoder = new TextEncoder();
15const FIXTURE_GIT_TOKEN = "fixture-token";
16const EXPECTED_GIT_AUTHORIZATION = `Basic ${btoa(`git:${FIXTURE_GIT_TOKEN}`)}`;
15 17
16const concat = (parts: readonly Uint8Array[]): Uint8Array => {18const concat = (parts: readonly Uint8Array[]): Uint8Array => {
17 const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));19 const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
@@ -72,6 +74,18 @@ const fixtureGitServer = Effect.acquireRelease( · 54 unmodified lines
72 let current = { sha: fixture.sha1, pack: new Uint8Array(fixture.pack1) };74 let current = { sha: fixture.sha1, pack: new Uint8Array(fixture.pack1) };
73 let packRequests = 0;75 let packRequests = 0;
74 const server = createServer((request: IncomingMessage, response: ServerResponse) => {76 const server = createServer((request: IncomingMessage, response: ServerResponse) => {
77 if (
78 (request.url === "/repo.git/info/refs?service=git-upload-pack" ||
79 request.url === "/repo.git/git-upload-pack") &&
80 request.headers.authorization !== EXPECTED_GIT_AUTHORIZATION
81 ) {
82 response.writeHead(401, {
83 "content-type": "text/plain",
84 "www-authenticate": 'Basic realm="custom-tools-fixture"',
85 });
86 response.end("authentication required");
87 return;
88 }
75 if (request.url === "/repo.git/info/refs?service=git-upload-pack") {89 if (request.url === "/repo.git/info/refs?service=git-upload-pack") {
76 response.writeHead(200, {90 response.writeHead(200, {
77 "content-type": "application/x-git-upload-pack-advertisement",91 "content-type": "application/x-git-upload-pack-advertisement",
@@ -208,7 +222,7 @@ const addSourceThroughConsole = (input: { · 130 unmodified lines
208 });222 });
209 223
210 await step("Sync the custom tools source", async () => {224 await step("Sync the custom tools source", async () => {
211 await page.locator('input[type="password"]').fill("fixture-token");225 await page.locator('input[type="password"]').fill(FIXTURE_GIT_TOKEN);
212 await page.getByRole("button", { name: "Sync source" }).click();226 await page.getByRole("button", { name: "Sync source" }).click();
213 await page.waitForURL(/\/integrations\/repo(?:\?|$)/, { timeout: 90_000 });227 await page.waitForURL(/\/integrations\/repo(?:\?|$)/, { timeout: 90_000 });
214 await page.getByLabel("Source").getByText("2 tools").waitFor({ timeout: 90_000 });228 await page.getByLabel("Source").getByText("2 tools").waitFor({ timeout: 90_000 });
@@ -221,6 +235,47 @@ const addSourceThroughConsole = (input: { · 6 unmodified lines
221 });235 });
222 });236 });
223 237
238const assertBadTokenFailureThroughConsole = (input: {
239 readonly target: TargetShape;
240 readonly browser: BrowserSurface;
241 readonly identity: Identity;
242 readonly sourceUrl: string;
243}) =>
244 input.browser.session(input.identity, async ({ page, step }) => {
245 await step("Try to sync the custom tools source with a bad token", async () => {
246 await page.goto(new URL("/integrations", input.target.baseUrl).toString(), {
247 waitUntil: "networkidle",
248 });
249 await page.getByRole("button", { name: "Connect", exact: true }).click();
250 const dialog = page.getByRole("dialog");
251 await dialog.getByRole("textbox").fill(input.sourceUrl);
252 await dialog.getByRole("button", { name: "Detect" }).click();
253 await page.getByRole("heading", { name: "Add custom tools" }).waitFor();
254 await page.locator('input[type="password"]').fill("wrong-token");
255 const syncResponse = page.waitForResponse(
256 (response) =>
257 response.url().includes("/api/apps/sources/repo/sync") && response.status() === 200,
258 { timeout: 90_000 },
259 );
260 await page.getByRole("button", { name: "Sync source" }).click();
261 await syncResponse;
262 });
263
264 await step("See the failed sync on the source panel", async () => {
265 await page.goto(new URL("/integrations/repo?tab=source", input.target.baseUrl).toString(), {
266 waitUntil: "networkidle",
267 });
268 await page.getByText("Last sync failed").waitFor({ timeout: 90_000 });
269 await page.getByText("source: info/refs 401").waitFor({ timeout: 90_000 });
270 });
271
272 await step("Remove the failed source before retrying with the good token", async () => {
273 await page.getByRole("button", { name: "Remove" }).click();
274 await page.getByRole("button", { name: "Remove source" }).click();
275 await page.waitForURL(/\/integrations(?:\?|$)/, { timeout: 90_000 });
276 });
277 });
278
224const syncSourceInConsole = (input: {279const syncSourceInConsole = (input: {
225 readonly target: TargetShape;280 readonly target: TargetShape;
226 readonly browser: BrowserSurface;281 readonly browser: BrowserSurface;
@@ -279,6 +334,20 @@ scenario( · 52 unmodified lines
279 expect(unauthorized.status, "sync requires authentication").toBe(401);334 expect(unauthorized.status, "sync requires authentication").toBe(401);
280 yield* Effect.promise(() => unauthorized.text());335 yield* Effect.promise(() => unauthorized.text());
281 336
337 const missingGitAuth = yield* Effect.promise(() =>
338 fetch(`${git.url}/info/refs?service=git-upload-pack`),
339 );
340 expect(missingGitAuth.status, "fixture git server requires auth").toBe(401);
341 yield* Effect.promise(() => missingGitAuth.text());
342
343 yield* assertBadTokenFailureThroughConsole({
344 target,
345 browser,
346 identity,
347 sourceUrl: git.url,
348 });
349 expect(git.packRequests(), "bad token fails before fetching a pack").toBe(0);
350
282 yield* addSourceThroughConsole({ target, browser, identity, sourceUrl: git.url });351 yield* addSourceThroughConsole({ target, browser, identity, sourceUrl: git.url });
283 352
284 const sources = yield* Effect.promise(() =>353 const sources = yield* Effect.promise(() =>
packages/plugins/apps/src/plugin/apps-plugin.test.ts+30−0
@@ -584,6 +584,36 @@ describe("apps source sync", () => { · 583 unmodified lines
584 ),584 ),
585 );585 );
586 586
587 it.effect("keeps the app source panel reachable after a source-stage sync failure", () =>
588 Effect.scoped(
589 Effect.gen(function* () {
590 const executor = yield* createExecutor(
591 makeTestConfig({
592 plugins: [
593 memoryCredentialsPlugin(),
594 makeAppsPlugin({ sourceKinds: ["local-directory"] }),
595 ] as const,
596 }),
597 );
598 yield* executor.apps.createSource({
599 kind: "local-directory",
600 slug: "missing-source",
601 app: "missing-tools",
602 path: "/definitely/missing/executor-app-source",
603 });
604
605 const result = yield* executor.apps.syncSource("missing-source");
606 expect(result.status).toBe("failed");
607 expect(result.errors?.[0]?.stage).toBe("source");
608 const integration = yield* executor.integrations.get(IntegrationSlug.make("missing-tools"));
609 expect(integration?.kind).toBe("apps");
610 expect(
611 yield* executor.tools.list({ integration: IntegrationSlug.make("missing-tools") }),
612 ).toEqual([]);
613 }),
614 ),
615 );
616
587 it.effect("rejects two app sources with the same app slug", () =>617 it.effect("rejects two app sources with the same app slug", () =>
588 Effect.scoped(618 Effect.scoped(
589 Effect.gen(function* () {619 Effect.gen(function* () {
packages/plugins/apps/src/plugin/apps-plugin.ts+14−1
@@ -152,19 +152,31 @@ const appDescription = (record: AppSourceRecord, description?: string): string = · 151 unmodified lines
152 return `Custom tools from ${record.config.path}`;152 return `Custom tools from ${record.config.path}`;
153};153};
154 154
155const ensureAppConnection = (155const ensureAppIntegration = (
156 ctx: PluginCtx<AppsStore>,156 ctx: PluginCtx<AppsStore>,
157 record: AppSourceRecord,157 record: AppSourceRecord,
158 description?: string,158 description?: string,
159): Effect.Effect<void, unknown> =>159): Effect.Effect<void, unknown> =>
160 Effect.gen(function* () {160 Effect.gen(function* () {
161 const appSlug = IntegrationSlug.make(record.app);161 const appSlug = IntegrationSlug.make(record.app);
162 const existing = yield* ctx.core.integrations.get(appSlug);
163 if (existing && existing.kind !== "apps") return;
162 yield* ctx.core.integrations.register({164 yield* ctx.core.integrations.register({
163 slug: appSlug,165 slug: appSlug,
164 name: record.app,166 name: record.app,
165 description: appDescription(record, description),167 description: appDescription(record, description),
166 config: {},168 config: {},
167 });169 });
170 });
171
172const ensureAppConnection = (
173 ctx: PluginCtx<AppsStore>,
174 record: AppSourceRecord,
175 description?: string,
176): Effect.Effect<void, unknown> =>
177 Effect.gen(function* () {
178 const appSlug = IntegrationSlug.make(record.app);
179 yield* ensureAppIntegration(ctx, record, description);
168 const existing = yield* ctx.connections.get({180 const existing = yield* ctx.connections.get({
169 owner: "org",181 owner: "org",
170 integration: appSlug,182 integration: appSlug,
@@ -341,6 +353,7 @@ const makeAppsExtension = ( · 170 unmodified lines
341 updatedAt: now(),353 updatedAt: now(),
342 };354 };
343 yield* ctx.storage.putSource(record, "org");355 yield* ctx.storage.putSource(record, "org");
356 yield* ensureAppIntegration(ctx, record);
344 return record;357 return record;
345 }),358 }),
346 deleteSource: (slug: string) =>359 deleteSource: (slug: string) =>