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
1212 import type { BrowserSurface } from "../src/surfaces/browser";
1313
1414 const textEncoder = new TextEncoder();
15+const FIXTURE_GIT_TOKEN = "fixture-token";
16+const EXPECTED_GIT_AUTHORIZATION = `Basic ${btoa(`git:${FIXTURE_GIT_TOKEN}`)}`;
1517
1618 const concat = (parts: readonly Uint8Array[]): Uint8Array => {
1719 const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
@@ -72,6 +74,18 @@ const fixtureGitServer = Effect.acquireRelease( · 54 unmodified lines
7274 let current = { sha: fixture.sha1, pack: new Uint8Array(fixture.pack1) };
7375 let packRequests = 0;
7476 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+ }
7589 if (request.url === "/repo.git/info/refs?service=git-upload-pack") {
7690 response.writeHead(200, {
7791 "content-type": "application/x-git-upload-pack-advertisement",
@@ -208,7 +222,7 @@ const addSourceThroughConsole = (input: { · 130 unmodified lines
208222 });
209223
210224 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);
212226 await page.getByRole("button", { name: "Sync source" }).click();
213227 await page.waitForURL(/\/integrations\/repo(?:\?|$)/, { timeout: 90_000 });
214228 await page.getByLabel("Source").getByText("2 tools").waitFor({ timeout: 90_000 });
@@ -221,6 +235,47 @@ const addSourceThroughConsole = (input: { · 6 unmodified lines
221235 });
222236 });
223237
238+const 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+
224279 const syncSourceInConsole = (input: {
225280 readonly target: TargetShape;
226281 readonly browser: BrowserSurface;
@@ -279,6 +334,20 @@ scenario( · 52 unmodified lines
279334 expect(unauthorized.status, "sync requires authentication").toBe(401);
280335 yield* Effect.promise(() => unauthorized.text());
281336
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+
282351 yield* addSourceThroughConsole({ target, browser, identity, sourceUrl: git.url });
283352
284353 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
584584 ),
585585 );
586586
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+
587617 it.effect("rejects two app sources with the same app slug", () =>
588618 Effect.scoped(
589619 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
152152 return `Custom tools from ${record.config.path}`;
153153 };
154154
155−const ensureAppConnection = (
155+const ensureAppIntegration = (
156156 ctx: PluginCtx<AppsStore>,
157157 record: AppSourceRecord,
158158 description?: string,
159159 ): Effect.Effect<void, unknown> =>
160160 Effect.gen(function* () {
161161 const appSlug = IntegrationSlug.make(record.app);
162+ const existing = yield* ctx.core.integrations.get(appSlug);
163+ if (existing && existing.kind !== "apps") return;
162164 yield* ctx.core.integrations.register({
163165 slug: appSlug,
164166 name: record.app,
165167 description: appDescription(record, description),
166168 config: {},
167169 });
170+ });
171+
172+const 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);
168180 const existing = yield* ctx.connections.get({
169181 owner: "org",
170182 integration: appSlug,
@@ -341,6 +353,7 @@ const makeAppsExtension = ( · 170 unmodified lines
341353 updatedAt: now(),
342354 };
343355 yield* ctx.storage.putSource(record, "org");
356+ yield* ensureAppIntegration(ctx, record);
344357 return record;
345358 }),
346359 deleteSource: (slug: string) =>