Unified · Split
| @@ -431,28 +431,59 @@ const DISCOVERY_BODIES: Readonly<Record<string, string>> = { · 430 unmodified lines | ||
| 431 | 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 | 443 | // A stub HTTP client that serves the canned Discovery document for whichever |
| 435 | 444 | // URL the bundle converter fetches. Service-hosted Discovery URLs carry their |
| 436 | 445 | // version in the query string, so match the full URL before falling back to the |
| 437 | 446 | // path-only key used by central Discovery URLs. |
| 438 | −const discoveryHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( | |
| 439 | − HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { | |
| 440 | − const url = new URL(request.url); | |
| 441 | − const key = `${url.origin}${url.pathname}`; | |
| 442 | − const body = DISCOVERY_BODIES[url.toString()] ?? DISCOVERY_BODIES[key]; | |
| 443 | − return Effect.succeed( | |
| 444 | − HttpClientResponse.fromWeb( | |
| 447 | +const makeDiscoveryHttpClientLayer = (options: DiscoveryHttpClientOptions = {}) => | |
| 448 | + Layer.succeed(HttpClient.HttpClient)( | |
| 449 | + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { | |
| 450 | + const url = new URL(request.url); | |
| 451 | + const key = `${url.origin}${url.pathname}`; | |
| 452 | + const body = DISCOVERY_BODIES[url.toString()] ?? DISCOVERY_BODIES[key]; | |
| 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 | 457 | request, |
| 446 | − body === undefined | |
| 447 | − ? new Response("not found", { status: 404 }) | |
| 448 | − : new Response(body, { | |
| 449 | − status: 200, | |
| 450 | − headers: { "content-type": "application/json" }, | |
| 451 | − }), | |
| 452 | − ), | |
| 453 | − ); | |
| 454 | − }), | |
| 455 | −); | |
| 458 | + failed | |
| 459 | + ? new Response("forced failure", { status: 503 }) | |
| 460 | + : body === undefined | |
| 461 | + ? new Response("not found", { status: 404 }) | |
| 462 | + : new Response(body, { | |
| 463 | + status: 200, | |
| 464 | + headers: { "content-type": "application/json" }, | |
| 465 | + }), | |
| 466 | + ); | |
| 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 | 488 | const bundlePlugins = () => |
| 458 | 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 | 967 | describe("Google health-check default", () => { |
| 705 | 968 | it.effect("default featured bundle auto-configures OAuth2 userinfo identity", () => |
| 706 | 969 | Effect.scoped( |