History

Test Google per-service add flow

Rhys Sullivan committed 4 months ago

Commit 5f33dba1 parent 9b819781 file · +280 −17Browse files

Unified · Split

packages/plugins/google/src/sdk/plugin.test.ts+280−17
@@ -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
434interface DiscoveryHttpClientOptions {
435 readonly failedUrls?: ReadonlySet<string>;
436 readonly beforeResponse?: (url: string) => Effect.Effect<void, never, never>;
437}
438
439const 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 whichever443// A stub HTTP client that serves the canned Discovery document for whichever
435// URL the bundle converter fetches. Service-hosted Discovery URLs carry their444// 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 the445// 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.
438const discoveryHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(447const 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 === undefined458 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
471const discoveryHttpClientLayer = makeDiscoveryHttpClientLayer();
472
473const 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
485const googleDiscoveryToolNames = (toolName: string): readonly string[] =>
486 [toolName, "oauth2.tokeninfo", "oauth2.userinfo.get", "oauth2.userinfo.v2.me.get"].sort();
456 487
457const bundlePlugins = () =>488const 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
735describe("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
704describe("Google health-check default", () => {967describe("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(