Unified · Split
| @@ -431,28 +431,59 @@ const DISCOVERY_BODIES: Readonly<Record<string, string>> = { · 430 unmodified lines | |||
| 431 | [OAUTH2_URL]: toJson(oauth2Doc), | 431 | [OAUTH2_URL]: toJson(oauth2Doc), |
| 432 | }; | 432 | }; |
| 433 | 433 | ||
| 434 | interface DiscoveryHttpClientOptions { | ||
| 435 | readonly failedUrls?: ReadonlySet<string>; | ||
| 436 | readonly beforeResponse?: (url: string) => Effect.Effect<void, never, never>; | ||
| 437 | } | ||
| 438 | |||
| 439 | const waitOneTurn: Effect.Effect<void, never, never> = Effect.promise(() => Promise.resolve()).pipe( | ||
| 440 | Effect.orDie, | ||
| 441 | ); | ||
| 442 | |||
| 434 | // A stub HTTP client that serves the canned Discovery document for whichever | 443 | // A stub HTTP client that serves the canned Discovery document for whichever |
| 435 | // URL the bundle converter fetches. Service-hosted Discovery URLs carry their | 444 | // URL the bundle converter fetches. Service-hosted Discovery URLs carry their |
| 436 | // version in the query string, so match the full URL before falling back to the | 445 | // version in the query string, so match the full URL before falling back to the |
| 437 | // path-only key used by central Discovery URLs. | 446 | // path-only key used by central Discovery URLs. |
| 438 | const discoveryHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( | 447 | const makeDiscoveryHttpClientLayer = (options: DiscoveryHttpClientOptions = {}) => |
| 439 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { | 448 | Layer.succeed(HttpClient.HttpClient)( |
| 440 | const url = new URL(request.url); | 449 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { |
| 441 | const key = `${url.origin}${url.pathname}`; | 450 | const url = new URL(request.url); |
| 442 | const body = DISCOVERY_BODIES[url.toString()] ?? DISCOVERY_BODIES[key]; | 451 | const key = `${url.origin}${url.pathname}`; |
| 443 | return Effect.succeed( | 452 | const body = DISCOVERY_BODIES[url.toString()] ?? DISCOVERY_BODIES[key]; |
| 444 | HttpClientResponse.fromWeb( | 453 | const discoveryUrl = DISCOVERY_BODIES[url.toString()] !== undefined ? url.toString() : key; |
| 454 | const failed = | ||
| 455 | options.failedUrls?.has(url.toString()) === true || options.failedUrls?.has(key) === true; | ||
| 456 | const response = HttpClientResponse.fromWeb( | ||
| 445 | request, | 457 | request, |
| 446 | body === undefined | 458 | failed |
| 447 | ? new Response("not found", { status: 404 }) | 459 | ? new Response("forced failure", { status: 503 }) |
| 448 | : new Response(body, { | 460 | : body === undefined |
| 449 | status: 200, | 461 | ? new Response("not found", { status: 404 }) |
| 450 | headers: { "content-type": "application/json" }, | 462 | : new Response(body, { |
| 451 | }), | 463 | status: 200, |
| 452 | ), | 464 | headers: { "content-type": "application/json" }, |
| 453 | ); | 465 | }), |
| 454 | }), | 466 | ); |
| 455 | ); | 467 | return (options.beforeResponse?.(discoveryUrl) ?? Effect.void).pipe(Effect.as(response)); |
| 468 | }), | ||
| 469 | ); | ||
| 470 | |||
| 471 | const discoveryHttpClientLayer = makeDiscoveryHttpClientLayer(); | ||
| 472 | |||
| 473 | const oauthScopesFromConfig = ( | ||
| 474 | config: { | ||
| 475 | readonly authenticationTemplate?: readonly { | ||
| 476 | readonly kind: string; | ||
| 477 | readonly scopes?: readonly string[]; | ||
| 478 | }[]; | ||
| 479 | } | null, | ||
| 480 | ): readonly string[] | undefined => { | ||
| 481 | const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | ||
| 482 | return oauth?.scopes ? [...oauth.scopes].sort() : undefined; | ||
| 483 | }; | ||
| 484 | |||
| 485 | const googleDiscoveryToolNames = (toolName: string): readonly string[] => | ||
| 486 | [toolName, "oauth2.tokeninfo", "oauth2.userinfo.get", "oauth2.userinfo.v2.me.get"].sort(); | ||
| 456 | 487 | ||
| 457 | const bundlePlugins = () => | 488 | const bundlePlugins = () => |
| 458 | [googlePlugin({ httpClientLayer: discoveryHttpClientLayer }), memoryCredentialsPlugin()] as const; | 489 | [googlePlugin({ httpClientLayer: discoveryHttpClientLayer }), memoryCredentialsPlugin()] as const; |
| @@ -701,6 +732,238 @@ describe("Google bundle add flow", () => { · 242 unmodified lines | |||
| 701 | ); | 732 | ); |
| 702 | }); | 733 | }); |
| 703 | 734 | ||
| 735 | describe("Google per-service add flow", () => { | ||
| 736 | const serviceExpectations = [ | ||
| 737 | { | ||
| 738 | presetId: "google-calendar", | ||
| 739 | slug: "google_calendar", | ||
| 740 | name: "Google Calendar", | ||
| 741 | description: "Calendars, events, ACLs, and scheduling.", | ||
| 742 | discoveryUrl: CALENDAR_URL, | ||
| 743 | scopes: ["email", "https://www.googleapis.com/auth/calendar", "openid", "profile"], | ||
| 744 | toolNames: googleDiscoveryToolNames("calendar.events.list"), | ||
| 745 | }, | ||
| 746 | { | ||
| 747 | presetId: "google-gmail", | ||
| 748 | slug: "google_gmail", | ||
| 749 | name: "Gmail", | ||
| 750 | description: "Messages, threads, labels, and drafts.", | ||
| 751 | discoveryUrl: GMAIL_URL, | ||
| 752 | scopes: ["email", "https://mail.google.com/", "openid", "profile"], | ||
| 753 | toolNames: googleDiscoveryToolNames("gmail.users.messages.list"), | ||
| 754 | }, | ||
| 755 | { | ||
| 756 | presetId: "google-drive", | ||
| 757 | slug: "google_drive", | ||
| 758 | name: "Google Drive", | ||
| 759 | description: "Files, folders, permissions, and shared drives.", | ||
| 760 | discoveryUrl: DRIVE_URL, | ||
| 761 | scopes: ["email", "https://www.googleapis.com/auth/drive", "openid", "profile"], | ||
| 762 | toolNames: googleDiscoveryToolNames("drive.files.list"), | ||
| 763 | }, | ||
| 764 | ] as const; | ||
| 765 | |||
| 766 | it.effect( | ||
| 767 | "addServices creates one integration per preset with exact scopes and health checks", | ||
| 768 | () => | ||
| 769 | Effect.scoped( | ||
| 770 | Effect.gen(function* () { | ||
| 771 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 772 | const connectedToolNames = (slug: string) => | ||
| 773 | Effect.gen(function* () { | ||
| 774 | yield* executor.connections.create({ | ||
| 775 | owner: "org", | ||
| 776 | name: ConnectionName.make(slug), | ||
| 777 | integration: IntegrationSlug.make(slug), | ||
| 778 | template: AuthTemplateSlug.make("googleOAuth2"), | ||
| 779 | value: "token-xyz", | ||
| 780 | }); | ||
| 781 | return (yield* executor.tools.list({ integration: IntegrationSlug.make(slug) })) | ||
| 782 | .map((tool) => String(tool.name)) | ||
| 783 | .sort(); | ||
| 784 | }); | ||
| 785 | |||
| 786 | const result = yield* executor.google.addServices({ | ||
| 787 | services: serviceExpectations.map((service) => ({ presetId: service.presetId })), | ||
| 788 | }); | ||
| 789 | |||
| 790 | expect(result).toEqual({ | ||
| 791 | added: serviceExpectations.map((service) => ({ | ||
| 792 | slug: IntegrationSlug.make(service.slug), | ||
| 793 | presetId: service.presetId, | ||
| 794 | toolCount: 4, | ||
| 795 | })), | ||
| 796 | skipped: [], | ||
| 797 | failed: [], | ||
| 798 | }); | ||
| 799 | |||
| 800 | const integrations = yield* executor.integrations.list(); | ||
| 801 | for (const service of serviceExpectations) { | ||
| 802 | const integration = integrations.find((item) => String(item.slug) === service.slug); | ||
| 803 | expect(integration?.name).toBe(service.name); | ||
| 804 | expect(integration?.description).toBe(service.description); | ||
| 805 | |||
| 806 | const config = yield* executor.google.getConfig(service.slug); | ||
| 807 | expect(config?.googleDiscoveryUrls).toEqual([service.discoveryUrl, OAUTH2_URL]); | ||
| 808 | expect(oauthScopesFromConfig(config)).toEqual([...service.scopes].sort()); | ||
| 809 | |||
| 810 | const stored = yield* executor.integrations.healthCheck.get( | ||
| 811 | IntegrationSlug.make(service.slug), | ||
| 812 | ); | ||
| 813 | expect(stored?.operation).toBe("oauth2.userinfo.get"); | ||
| 814 | expect(stored?.args).toBeUndefined(); | ||
| 815 | expect(stored?.identityField).toBe("email"); | ||
| 816 | |||
| 817 | expect(yield* connectedToolNames(service.slug)).toEqual(service.toolNames); | ||
| 818 | } | ||
| 819 | }), | ||
| 820 | ), | ||
| 821 | ); | ||
| 822 | |||
| 823 | it.effect( | ||
| 824 | "addServices isolates a Discovery fetch failure and keeps other services registered", | ||
| 825 | () => | ||
| 826 | Effect.scoped( | ||
| 827 | Effect.gen(function* () { | ||
| 828 | const failingLayer = makeDiscoveryHttpClientLayer({ | ||
| 829 | failedUrls: new Set([GMAIL_URL]), | ||
| 830 | }); | ||
| 831 | const executor = yield* createExecutor( | ||
| 832 | makeTestConfig({ | ||
| 833 | plugins: [googlePlugin({ httpClientLayer: failingLayer }), memoryCredentialsPlugin()], | ||
| 834 | }), | ||
| 835 | ); | ||
| 836 | const connectedToolNames = (slug: string) => | ||
| 837 | Effect.gen(function* () { | ||
| 838 | yield* executor.connections.create({ | ||
| 839 | owner: "org", | ||
| 840 | name: ConnectionName.make(slug), | ||
| 841 | integration: IntegrationSlug.make(slug), | ||
| 842 | template: AuthTemplateSlug.make("googleOAuth2"), | ||
| 843 | value: "token-xyz", | ||
| 844 | }); | ||
| 845 | return (yield* executor.tools.list({ integration: IntegrationSlug.make(slug) })) | ||
| 846 | .map((tool) => String(tool.name)) | ||
| 847 | .sort(); | ||
| 848 | }); | ||
| 849 | |||
| 850 | const result = yield* executor.google.addServices({ | ||
| 851 | services: [ | ||
| 852 | { presetId: "google-calendar" }, | ||
| 853 | { presetId: "google-gmail" }, | ||
| 854 | { presetId: "google-drive" }, | ||
| 855 | ], | ||
| 856 | }); | ||
| 857 | |||
| 858 | expect(result).toEqual({ | ||
| 859 | added: [ | ||
| 860 | { | ||
| 861 | slug: IntegrationSlug.make("google_calendar"), | ||
| 862 | presetId: "google-calendar", | ||
| 863 | toolCount: 4, | ||
| 864 | }, | ||
| 865 | { | ||
| 866 | slug: IntegrationSlug.make("google_drive"), | ||
| 867 | presetId: "google-drive", | ||
| 868 | toolCount: 4, | ||
| 869 | }, | ||
| 870 | ], | ||
| 871 | skipped: [], | ||
| 872 | failed: [ | ||
| 873 | { | ||
| 874 | slug: IntegrationSlug.make("google_gmail"), | ||
| 875 | presetId: "google-gmail", | ||
| 876 | error: "Failed to fetch Google Discovery document: HTTP 503", | ||
| 877 | }, | ||
| 878 | ], | ||
| 879 | }); | ||
| 880 | |||
| 881 | expect(yield* executor.google.getIntegration("google_gmail")).toBeNull(); | ||
| 882 | expect(yield* connectedToolNames("google_calendar")).toEqual( | ||
| 883 | googleDiscoveryToolNames("calendar.events.list"), | ||
| 884 | ); | ||
| 885 | expect(yield* connectedToolNames("google_drive")).toEqual( | ||
| 886 | googleDiscoveryToolNames("drive.files.list"), | ||
| 887 | ); | ||
| 888 | }), | ||
| 889 | ), | ||
| 890 | ); | ||
| 891 | |||
| 892 | it.effect("addServices skips an existing service without duplicating integration rows", () => | ||
| 893 | Effect.scoped( | ||
| 894 | Effect.gen(function* () { | ||
| 895 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 896 | |||
| 897 | yield* executor.google.addServices({ | ||
| 898 | services: [{ presetId: "google-calendar" }], | ||
| 899 | }); | ||
| 900 | const result = yield* executor.google.addServices({ | ||
| 901 | services: [{ presetId: "google-calendar" }], | ||
| 902 | }); | ||
| 903 | |||
| 904 | expect(result).toEqual({ | ||
| 905 | added: [], | ||
| 906 | skipped: [ | ||
| 907 | { | ||
| 908 | slug: IntegrationSlug.make("google_calendar"), | ||
| 909 | presetId: "google-calendar", | ||
| 910 | reason: "already_exists", | ||
| 911 | }, | ||
| 912 | ], | ||
| 913 | failed: [], | ||
| 914 | }); | ||
| 915 | |||
| 916 | const integrations = yield* executor.integrations.list(); | ||
| 917 | expect( | ||
| 918 | integrations.filter((integration) => String(integration.slug) === "google_calendar"), | ||
| 919 | ).toHaveLength(1); | ||
| 920 | }), | ||
| 921 | ), | ||
| 922 | ); | ||
| 923 | |||
| 924 | it.effect("addServices starts each service Discovery fetch sequentially", () => | ||
| 925 | Effect.scoped( | ||
| 926 | Effect.gen(function* () { | ||
| 927 | let activeServiceFetches = 0; | ||
| 928 | let maxActiveServiceFetches = 0; | ||
| 929 | const serviceFetchOrder: string[] = []; | ||
| 930 | const sequentialLayer = makeDiscoveryHttpClientLayer({ | ||
| 931 | beforeResponse: (url) => { | ||
| 932 | if (url === OAUTH2_URL) return Effect.void; | ||
| 933 | return Effect.gen(function* () { | ||
| 934 | activeServiceFetches += 1; | ||
| 935 | maxActiveServiceFetches = Math.max(maxActiveServiceFetches, activeServiceFetches); | ||
| 936 | serviceFetchOrder.push(url); | ||
| 937 | yield* waitOneTurn; | ||
| 938 | activeServiceFetches -= 1; | ||
| 939 | }); | ||
| 940 | }, | ||
| 941 | }); | ||
| 942 | const executor = yield* createExecutor( | ||
| 943 | makeTestConfig({ | ||
| 944 | plugins: [ | ||
| 945 | googlePlugin({ httpClientLayer: sequentialLayer }), | ||
| 946 | memoryCredentialsPlugin(), | ||
| 947 | ], | ||
| 948 | }), | ||
| 949 | ); | ||
| 950 | |||
| 951 | const result = yield* executor.google.addServices({ | ||
| 952 | services: [ | ||
| 953 | { presetId: "google-calendar" }, | ||
| 954 | { presetId: "google-gmail" }, | ||
| 955 | { presetId: "google-drive" }, | ||
| 956 | ], | ||
| 957 | }); | ||
| 958 | |||
| 959 | expect(result.failed).toEqual([]); | ||
| 960 | expect(serviceFetchOrder).toEqual([CALENDAR_URL, GMAIL_URL, DRIVE_URL]); | ||
| 961 | expect(maxActiveServiceFetches).toBe(1); | ||
| 962 | }), | ||
| 963 | ), | ||
| 964 | ); | ||
| 965 | }); | ||
| 966 | |||
| 704 | describe("Google health-check default", () => { | 967 | describe("Google health-check default", () => { |
| 705 | it.effect("default featured bundle auto-configures OAuth2 userinfo identity", () => | 968 | it.effect("default featured bundle auto-configures OAuth2 userinfo identity", () => |
| 706 | Effect.scoped( | 969 | Effect.scoped( |