History

Fix Microsoft service split ownership

Rhys Sullivan committed 4 months ago

Commit 0ed72ef1 parent 56452be2 files · +467 −31Browse files

Unified · Split

2 files
  1. scripts/migration/service-split-planner.ts +190 −31
  2. tests/migration/service-split-planner.test.ts +277 −0
scripts/migration/service-split-planner.ts+190−31
@@ -337,6 +337,121 @@ const microsoftCatalogByMonolithPresetId: ReadonlyMap<string, OpenApiPreset> = n · 336 unmodified lines
337 microsoftCatalog.map((preset) => [preset.id.replace(/^microsoft-/, ""), preset]),337 microsoftCatalog.map((preset) => [preset.id.replace(/^microsoft-/, ""), preset]),
338);338);
339 339
340const MICROSOFT_UNMIGRATABLE_UMBRELLA_PRESET_IDS = new Set(["me-surface", "users", "groups"]);
341const MICROSOFT_TOOL_IMPLIED_PRESET_IDS = new Set(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS);
342const microsoftCatalogBackedPresetIds = new Set(
343 microsoftCatalog
344 .filter((preset) => preset.defaultSlug && preset.defaultSlug.length > 0)
345 .map((preset) => preset.id.replace(/^microsoft-/, "")),
346);
347
348export const microsoftToolFirstSegmentPresetIds: ReadonlyMap<string, readonly string[]> = new Map([
349 ["chats", ["teams-chat"]],
350 ["chatsChat", ["teams-chat"]],
351 ["communications", ["meetings-calls"]],
352 ["communicationsCall", ["meetings-calls"]],
353 ["communicationsOnlineMeeting", ["meetings-calls"]],
354 ["communicationsPresence", ["meetings-calls"]],
355 ["drives", ["files"]],
356 ["drivesDrive", ["files"]],
357 ["drivesDriveItem", ["files"]],
358 ["groupsDrive", ["files"]],
359 ["groupsDrives", ["files"]],
360 ["groupsOnenote", ["onenote"]],
361 ["groupsTeam", ["teams-channels"]],
362 ["meCalendar", ["calendar"]],
363 ["meCalendarGroups", ["calendar"]],
364 ["meCalendars", ["calendar"]],
365 ["meCalendarView", ["calendar"]],
366 ["meChat", ["teams-chat"]],
367 ["meChats", ["teams-chat"]],
368 ["meContact", ["contacts"]],
369 ["meContactFolder", ["contacts"]],
370 ["meContactFolders", ["contacts"]],
371 ["meContacts", ["contacts"]],
372 ["meDrive", ["files"]],
373 ["meDrives", ["files"]],
374 ["meEvent", ["calendar"]],
375 ["meEvents", ["calendar"]],
376 ["meFindMeetingTimes", ["calendar"]],
377 ["meFollowedSites", ["files"]],
378 ["meGetMailTips", ["mail"]],
379 ["meInferenceClassification", ["mail"]],
380 ["meJoinedTeams", ["teams-channels"]],
381 ["meMailFolders", ["mail"]],
382 ["meMailboxSettings", ["mail"]],
383 ["meMessage", ["mail"]],
384 ["meMessages", ["mail"]],
385 ["meOnlineMeeting", ["meetings-calls"]],
386 ["meOnlineMeetings", ["meetings-calls"]],
387 ["meOnenote", ["onenote"]],
388 ["meOutlook", ["mail"]],
389 ["meOutlookUser", ["mail"]],
390 ["mePeople", ["contacts"]],
391 ["mePerson", ["contacts"]],
392 ["mePhoto", ["profile"]],
393 ["meProfilePhoto", ["profile"]],
394 ["meReminderView", ["calendar"]],
395 ["meSendMail", ["mail"]],
396 ["meSite", ["files"]],
397 ["meTeam", ["teams-channels"]],
398 ["meTodo", ["tasks"]],
399 ["meUser", ["profile"]],
400 ["meUserActions", ["profile"]],
401 ["meUserFunctions", ["profile"]],
402 ["shares", ["files"]],
403 ["sites", ["sites"]],
404 ["sitesOnenote", ["onenote"]],
405 ["sitesSite", ["sites"]],
406 ["teams", ["teams-channels"]],
407 ["teamsChannel", ["teams-channels"]],
408 ["teamsTeam", ["teams-channels"]],
409 ["teamsTemplates", ["teams-channels"]],
410 ["teamwork", ["teams-channels"]],
411 ["usersCalendar", ["calendar"]],
412 ["usersCalendarGroups", ["calendar"]],
413 ["usersCalendars", ["calendar"]],
414 ["usersCalendarView", ["calendar"]],
415 ["usersContact", ["contacts"]],
416 ["usersContactFolder", ["contacts"]],
417 ["usersContactFolders", ["contacts"]],
418 ["usersContacts", ["contacts"]],
419 ["usersDrive", ["files"]],
420 ["usersDrives", ["files"]],
421 ["usersEvent", ["calendar"]],
422 ["usersEvents", ["calendar"]],
423 ["usersFindMeetingTimes", ["calendar"]],
424 ["usersMailFolders", ["mail"]],
425 ["usersMessage", ["mail"]],
426 ["usersMessages", ["mail"]],
427 ["usersOnlineMeeting", ["meetings-calls"]],
428 ["usersOnlineMeetings", ["meetings-calls"]],
429 ["usersOnenote", ["onenote"]],
430 ["usersOutlook", ["mail"]],
431 ["usersPeople", ["contacts"]],
432 ["usersPerson", ["contacts"]],
433 ["usersReminderView", ["calendar"]],
434 ["usersSendMail", ["mail"]],
435 ["usersTodo", ["tasks"]],
436]);
437
438const MICROSOFT_WORKBOOK_FIRST_SEGMENTS = new Set([
439 "drivesDriveItem",
440 "groupsDrive",
441 "meDrive",
442 "usersDrive",
443]);
444
445const microsoftFirstSegmentOwnershipKey = (firstSegment: string): string | undefined =>
446 [...microsoftToolFirstSegmentPresetIds.keys()]
447 .filter(
448 (key) =>
449 firstSegment === key ||
450 (firstSegment.startsWith(key) &&
451 firstSegment[key.length]?.toUpperCase() === firstSegment[key.length]),
452 )
453 .sort((left, right) => right.length - left.length)[0];
454
340const serviceSlugForPreset = (family: MonolithPluginId, presetId: string): string | undefined => {455const serviceSlugForPreset = (family: MonolithPluginId, presetId: string): string | undefined => {
341 const preset =456 const preset =
342 family === "google"457 family === "google"
@@ -405,6 +520,13 @@ const microsoftPresetIdsFromConfig = (integration: IntegrationRow): readonly str · 62 unmodified lines
405 );520 );
406};521};
407 522
523const microsoftUnmigratablePresetIds = (presetIds: readonly string[]): readonly string[] =>
524 presetIds.filter(
525 (presetId) =>
526 MICROSOFT_UNMIGRATABLE_UMBRELLA_PRESET_IDS.has(presetId) ||
527 !microsoftCatalogBackedPresetIds.has(presetId),
528 );
529
408export const googlePresetIdForTool = (toolName: string): string | undefined => {530export const googlePresetIdForTool = (toolName: string): string | undefined => {
409 const [first, second] = toolName.split(".");531 const [first, second] = toolName.split(".");
410 if (first === "admin") {532 if (first === "admin") {
@@ -413,38 +535,20 @@ export const googlePresetIdForTool = (toolName: string): string | undefined => { · 2 unmodified lines
413 return GOOGLE_TOOL_PREFIX_TO_PRESET_ID.get(first ?? "");535 return GOOGLE_TOOL_PREFIX_TO_PRESET_ID.get(first ?? "");
414};536};
415 537
416const microsoftPresetIdsForTool = (toolName: string): readonly string[] => {538export const microsoftPresetIdsForTool = (toolName: string): readonly string[] => {
417 const segments = toolName.split(".");539 const segments = toolName.split(".");
418 const first = segments[0] ?? "";540 const first = segments[0] ?? "";
419 const presetIds: string[] = [];541 const ownershipKey = microsoftFirstSegmentOwnershipKey(first);
420 const add = (presetId: string) => {542 const fromFirstSegment = ownershipKey
421 if (!presetIds.includes(presetId)) presetIds.push(presetId);543 ? (microsoftToolFirstSegmentPresetIds.get(ownershipKey) ?? [])
422 };544 : [];
423 545 const workbookOwned =
424 if (/^me/.test(first)) add("me-surface");546 ownershipKey !== undefined &&
425 if (/^users/.test(first)) add("users");547 MICROSOFT_WORKBOOK_FIRST_SEGMENTS.has(ownershipKey) &&
426 if (/^groups/.test(first)) add("groups");548 segments.some((segment) => segment.toLowerCase().includes("workbook"));
427 if (/^(me|users).*Message|^meMail|^usersMail|^meMailbox|^meOutlook|^usersOutlook/.test(first)) {549 return unique([...fromFirstSegment, ...(workbookOwned ? ["excel"] : [])]).filter((presetId) =>
428 add("mail");550 MICROSOFT_TOOL_IMPLIED_PRESET_IDS.has(presetId),
429 }551 );
430 if (/Calendar|Event|Reminder|findMeetingTimes/.test(first)) add("calendar");
431 if (/Contact|Person/.test(first)) add("contacts");
432 if (/Todo/.test(first)) add("tasks");
433 if (/Planner/.test(first)) add("planner");
434 if (/Drive|drives|shares/.test(first)) add("files");
435 if (segments.some((segment) => /workbook/i.test(segment)) || /Workbook|Excel/.test(first)) {
436 if (/^(me|users|groups)/.test(first) || /Drive|drives/.test(first)) add("files");
437 add("excel");
438 }
439 if (/sites|Site|List|SharePoint/.test(first)) add("sites");
440 if (/onenote/i.test(first) || segments.some((segment) => /onenote/i.test(segment))) {
441 add("onenote");
442 }
443 if (/chats|Chat/.test(first)) add("teams-chat");
444 if (/teams|Team|teamwork|Channel/.test(first)) add("teams-channels");
445 if (/communications|OnlineMeeting|Call|Presence/.test(first)) add("meetings-calls");
446 if (/^meUser|^usersUser|ProfilePhoto/.test(first)) add("profile");
447 return presetIds;
448};552};
449 553
450const presetIdsForTool = (pluginId: MonolithPluginId, toolName: string): readonly string[] => {554const presetIdsForTool = (pluginId: MonolithPluginId, toolName: string): readonly string[] => {
@@ -462,6 +566,14 @@ const deriveServices = ( · 11 unmodified lines
462 pluginId === "google"566 pluginId === "google"
463 ? googlePresetIdsFromConfig(integration)567 ? googlePresetIdsFromConfig(integration)
464 : microsoftPresetIdsFromConfig(integration);568 : microsoftPresetIdsFromConfig(integration);
569 if (pluginId === "microsoft") {
570 const unmigratablePresetIds = microsoftUnmigratablePresetIds(fromConfig);
571 if (unmigratablePresetIds.length > 0) {
572 throw new Error(
573 `Monolith ${tenantHash(integration.tenant)}/${integration.plugin_id}/${integration.slug} references unmigratable-umbrella Microsoft Graph preset(s): ${unmigratablePresetIds.join(", ")}; org needs manual handling`,
574 );
575 }
576 }
465 const fromTools = unique(577 const fromTools = unique(
466 tools.flatMap((tool) => {578 tools.flatMap((tool) => {
467 const presetIds = presetIdsForTool(pluginId, tool.name);579 const presetIds = presetIdsForTool(pluginId, tool.name);
@@ -578,7 +690,7 @@ const operationRowsForService = ( · 110 unmodified lines
578 if (operationIntegration(row) !== monolith.slug) return false;690 if (operationIntegration(row) !== monolith.slug) return false;
579 const toolName = operationToolName(row);691 const toolName = operationToolName(row);
580 if (!toolName) return false;692 if (!toolName) return false;
581 if (monolith.plugin_id === "google" && toolName.startsWith("oauth2.")) return false;693 if (isIntentionallyDroppedTool(monolith.plugin_id as MonolithPluginId, toolName)) return false;
582 return serviceForMatchedTool(monolith.plugin_id as MonolithPluginId, toolName, [694 return serviceForMatchedTool(monolith.plugin_id as MonolithPluginId, toolName, [
583 target,695 target,
584 ]).includes(target.slug);696 ]).includes(target.slug);
@@ -602,6 +714,9 @@ const isConfigOnlyMonolith = (input: { · 17 unmodified lines
602}): boolean =>714}): boolean =>
603 input.tools.length === 0 && input.connections.length === 0 && input.operations.length === 0;715 input.tools.length === 0 && input.connections.length === 0 && input.operations.length === 0;
604 716
717const isIntentionallyDroppedTool = (pluginId: MonolithPluginId, toolName: string): boolean =>
718 pluginId === "google" && toolName.startsWith("oauth2.");
719
605const serviceForMatchedTool = (720const serviceForMatchedTool = (
606 pluginId: MonolithPluginId,721 pluginId: MonolithPluginId,
607 toolName: string,722 toolName: string,
@@ -617,6 +732,43 @@ const serviceForMatchedTool = ( · 9 unmodified lines
617const allServiceSlugs = (services: readonly ServiceTarget[]): readonly string[] =>732const allServiceSlugs = (services: readonly ServiceTarget[]): readonly string[] =>
618 services.map((service) => service.slug);733 services.map((service) => service.slug);
619 734
735const unassignedToolNames = (
736 pluginId: MonolithPluginId,
737 tools: readonly { readonly name: string }[],
738 services: readonly ServiceTarget[],
739): readonly string[] =>
740 unique(
741 tools.flatMap((tool) => {
742 if (isIntentionallyDroppedTool(pluginId, tool.name)) return [];
743 return serviceForMatchedTool(pluginId, tool.name, services).length === 0 ? [tool.name] : [];
744 }),
745 );
746
747const assertNoUnassignedRows = (
748 monolith: IntegrationRow,
749 tools: readonly ToolRow[],
750 operations: readonly PluginStorageRow[],
751 services: readonly ServiceTarget[],
752): void => {
753 const pluginId = monolith.plugin_id as MonolithPluginId;
754 const unassignedTools = unassignedToolNames(pluginId, tools, services);
755 if (unassignedTools.length > 0) {
756 throw new Error(
757 `Monolith ${tenantHash(monolith.tenant)}/${monolith.plugin_id}/${monolith.slug} has tool row(s) with no target service: ${unassignedTools.slice(0, 20).join(", ")}`,
758 );
759 }
760 const operationTools = operations.flatMap((row) => {
761 const toolName = operationToolName(row);
762 return toolName ? [{ name: toolName }] : [];
763 });
764 const unassignedOperations = unassignedToolNames(pluginId, operationTools, services);
765 if (unassignedOperations.length > 0) {
766 throw new Error(
767 `Monolith ${tenantHash(monolith.tenant)}/${monolith.plugin_id}/${monolith.slug} has operation row(s) with no target service: ${unassignedOperations.slice(0, 20).join(", ")}`,
768 );
769 }
770};
771
620const serviceSlugsForToolPattern = (772const serviceSlugsForToolPattern = (
621 pluginId: MonolithPluginId,773 pluginId: MonolithPluginId,
622 toolSegments: readonly string[],774 toolSegments: readonly string[],
@@ -880,6 +1032,13 @@ export const planMigration = (input: MigrationInput): MigrationPlan => { · 257 unmodified lines
880 ) {1032 ) {
881 continue;1033 continue;
882 }1034 }
1035 try {
1036 assertNoUnassignedRows(monolith, monolithTools, monolithOperations, services);
1037 } catch (error) {
1038 if (!input.collectPolicyErrors) throw error;
1039 hardErrors.push(error instanceof Error ? error.message : String(error));
1040 continue;
1041 }
883 const specHash = specHashFor(monolith);1042 const specHash = specHashFor(monolith);
884 const namespace = pluginBlobNamespace(tenant, monolith.plugin_id);1043 const namespace = pluginBlobNamespace(tenant, monolith.plugin_id);
885 const targetNamespace = pluginBlobNamespace(tenant, "openapi");1044 const targetNamespace = pluginBlobNamespace(tenant, "openapi");
tests/migration/service-split-planner.test.ts+277−0
@@ -1,7 +1,14 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3import {
4 streamOperationBindingsFromStructure,
5 structuralSplit,
6} from "../../packages/plugins/openapi/src/sdk";
2 7
3import {8import {
4 googlePresetIdForTool,9 googlePresetIdForTool,
10 microsoftPresetIdsForTool,
11 microsoftToolFirstSegmentPresetIds,
5 operationStorageKey,12 operationStorageKey,
6 planMigration,13 planMigration,
7 verifyPolicyRewriteNeverWidens,14 verifyPolicyRewriteNeverWidens,
@@ -161,6 +168,56 @@ const googleCatalogMethodPrefixFixtures: ReadonlyMap<string, readonly string[]> · 153 unmodified lines
161 ["google-cloud-resource-manager", ["cloudresourcemanager.projects.list"]],168 ["google-cloud-resource-manager", ["cloudresourcemanager.projects.list"]],
162]);169]);
163 170
171const microsoftDefaultPresetIds = [
172 "profile",
173 "mail",
174 "calendar",
175 "contacts",
176 "tasks",
177 "files",
178 "excel",
179 "sites",
180 "onenote",
181 "teams-chat",
182 "teams-channels",
183 "meetings-calls",
184] as const;
185
186const microsoftFixtureTagForFirstSegment = (firstSegment: string): string =>
187 firstSegment.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
188
189const microsoftToolFirstSegmentsFromSplitterFixture = () =>
190 Effect.gen(function* () {
191 const paths = [...microsoftToolFirstSegmentPresetIds.keys()]
192 .map(
193 (firstSegment) => ` /fixture/${firstSegment}:
194 get:
195 tags:
196 - ${microsoftFixtureTagForFirstSegment(firstSegment)}
197 operationId: ${firstSegment}.Get
198 responses:
199 "200":
200 description: OK`,
201 )
202 .join("\n");
203 const structure = structuralSplit(`openapi: 3.0.4
204info:
205 title: Microsoft Graph Tool Ownership Fixture
206 version: v1.0
207paths:
208${paths}
209components: {}
210`);
211 expect(structure).not.toBeNull();
212 const toolNames: string[] = [];
213 yield* streamOperationBindingsFromStructure(structure!, { chunkSize: 100 }, (chunk) =>
214 Effect.sync(() => {
215 toolNames.push(...chunk.map((row) => row.toolName));
216 }),
217 );
218 return new Set(toolNames.map((toolName) => toolName.split(".")[0] ?? ""));
219 });
220
164const input = (overrides: Partial<MigrationInput> = {}): MigrationInput => ({221const input = (overrides: Partial<MigrationInput> = {}): MigrationInput => ({
165 integrations: [integration()],222 integrations: [integration()],
166 connections: [connection()],223 connections: [connection()],
@@ -196,6 +253,23 @@ describe("provider service split migration planner", () => { · 29 unmodified lines
196 }253 }
197 });254 });
198 255
256 it.effect("keeps Microsoft planner ownership table aligned with splitter tool names", () =>
257 Effect.gen(function* () {
258 const splitterFirstSegments = yield* microsoftToolFirstSegmentsFromSplitterFixture();
259
260 expect([...splitterFirstSegments].sort()).toEqual(
261 [...microsoftToolFirstSegmentPresetIds.keys()].sort(),
262 );
263 for (const [firstSegment, presetIds] of microsoftToolFirstSegmentPresetIds) {
264 expect(microsoftPresetIdsForTool(`${firstSegment}.get`), firstSegment).toEqual(presetIds);
265 }
266 expect(microsoftPresetIdsForTool("drivesDriveItem.workbook.worksheets.list")).toEqual([
267 "files",
268 "excel",
269 ]);
270 }),
271 );
272
199 it("plans a single-service monolith split", () => {273 it("plans a single-service monolith split", () => {
200 const plan = planMigration(input());274 const plan = planMigration(input());
201 275
@@ -747,6 +821,209 @@ describe("provider service split migration planner", () => { · 545 unmodified lines
747 expect(plan.summary.hardErrorOrgs).toBe(0);821 expect(plan.summary.hardErrorOrgs).toBe(0);
748 });822 });
749 823
824 it("maps Microsoft me-surface tools to only configured specific presets", () => {
825 const toolNames = [
826 "meInferenceClassification",
827 "meMailboxSettings",
828 "meOutlookUser",
829 "mePerson",
830 "meSite",
831 "meDrive",
832 "meTeam",
833 "meUser",
834 "meUserActions",
835 "meUserFunctions",
836 "meChat",
837 "meOnlineMeeting",
838 ];
839 const migrationInput = input({
840 integrations: [
841 integration({
842 plugin_id: "microsoft",
843 slug: "microsoft_graph_api",
844 name: "Microsoft Graph API",
845 config: {
846 microsoftGraphPresetIds: microsoftDefaultPresetIds,
847 specHash: "graph-hash",
848 },
849 }),
850 ],
851 connections: [
852 connection({
853 integration: "microsoft_graph_api",
854 template: "microsoftOAuth",
855 oauth_client: "microsoft",
856 }),
857 ],
858 tools: toolNames.map((name) =>
859 tool(name, {
860 integration: "microsoft_graph_api",
861 plugin_id: "microsoft",
862 }),
863 ),
864 pluginStorage: toolNames.map((name) =>
865 operation(name, {
866 plugin_id: "microsoft",
867 key: operationStorageKey("microsoft_graph_api", name),
868 data: {
869 integration: "microsoft_graph_api",
870 toolName: name,
871 },
872 }),
873 ),
874 blobs: [
875 blob("spec/graph-hash", { namespace: "o:org_1/microsoft" }),
876 blob("defs/graph-hash", { namespace: "o:org_1/microsoft" }),
877 ],
878 });
879 const plan = planMigration(migrationInput);
880 const bySlug = new Map(plan.orgs[0]?.integrations.map((row) => [row.target.slug, row]));
881
882 expect(microsoftPresetIdsForTool("meInferenceClassification")).toEqual(["mail"]);
883 expect(microsoftPresetIdsForTool("meMailboxSettings")).toEqual(["mail"]);
884 expect(microsoftPresetIdsForTool("meOutlookUser")).toEqual(["mail"]);
885 expect(microsoftPresetIdsForTool("mePerson")).toEqual(["contacts"]);
886 expect(microsoftPresetIdsForTool("meSite")).toEqual(["files"]);
887 expect(microsoftPresetIdsForTool("meDrive")).toEqual(["files"]);
888 expect(microsoftPresetIdsForTool("meTeam")).toEqual(["teams-channels"]);
889 expect(microsoftPresetIdsForTool("meUser")).toEqual(["profile"]);
890 expect(microsoftPresetIdsForTool("meUserActions")).toEqual(["profile"]);
891 expect(microsoftPresetIdsForTool("meUserFunctions")).toEqual(["profile"]);
892 expect(microsoftPresetIdsForTool("meChat")).toEqual(["teams-chat"]);
893 expect(microsoftPresetIdsForTool("meOnlineMeeting")).toEqual(["meetings-calls"]);
894 expect(bySlug.get("microsoft_mail")?.servingState.operationToolNames).toEqual([
895 "meInferenceClassification",
896 "meMailboxSettings",
897 "meOutlookUser",
898 ]);
899 expect(bySlug.get("microsoft_contacts")?.servingState.operationToolNames).toEqual(["mePerson"]);
900 expect(bySlug.get("microsoft_files")?.servingState.operationToolNames).toEqual([
901 "meSite",
902 "meDrive",
903 ]);
904 expect(bySlug.get("microsoft_teams_channels")?.servingState.operationToolNames).toEqual([
905 "meTeam",
906 ]);
907 expect(bySlug.get("microsoft_profile")?.servingState.operationToolNames).toEqual([
908 "meUser",
909 "meUserActions",
910 "meUserFunctions",
911 ]);
912 expect(bySlug.get("microsoft_teams_chat")?.servingState.operationToolNames).toEqual(["meChat"]);
913 expect(bySlug.get("microsoft_meetings_calls")?.servingState.operationToolNames).toEqual([
914 "meOnlineMeeting",
915 ]);
916 expect(plan.summary.hardErrorOrgs).toBe(0);
917 });
918
919 it("hard-errors when a Microsoft operation row maps to no target service", () => {
920 expect(() =>
921 planMigration(
922 input({
923 integrations: [
924 integration({
925 plugin_id: "microsoft",
926 slug: "microsoft_graph_api",
927 name: "Microsoft Graph API",
928 config: {
929 microsoftGraphPresetIds: ["mail"],
930 specHash: "graph-hash",
931 },
932 }),
933 ],
934 connections: [
935 connection({
936 integration: "microsoft_graph_api",
937 template: "microsoftOAuth",
938 oauth_client: "microsoft",
939 }),
940 ],
941 tools: [
942 tool("meMessagesList", {
943 integration: "microsoft_graph_api",
944 plugin_id: "microsoft",
945 }),
946 ],
947 pluginStorage: [
948 operation("unknownGraphThing", {
949 plugin_id: "microsoft",
950 key: operationStorageKey("microsoft_graph_api", "unknownGraphThing"),
951 data: {
952 integration: "microsoft_graph_api",
953 toolName: "unknownGraphThing",
954 },
955 }),
956 ],
957 blobs: [
958 blob("spec/graph-hash", { namespace: "o:org_1/microsoft" }),
959 blob("defs/graph-hash", { namespace: "o:org_1/microsoft" }),
960 ],
961 }),
962 ),
963 ).toThrow(/operation row\(s\) with no target service: unknownGraphThing/);
964 });
965
966 it("hard-errors when a Microsoft tool row maps to no target service", () => {
967 expect(() =>
968 planMigration(
969 input({
970 integrations: [
971 integration({
972 plugin_id: "microsoft",
973 slug: "microsoft_graph_api",
974 name: "Microsoft Graph API",
975 config: {
976 microsoftGraphPresetIds: ["mail"],
977 specHash: "graph-hash",
978 },
979 }),
980 ],
981 connections: [
982 connection({
983 integration: "microsoft_graph_api",
984 template: "microsoftOAuth",
985 oauth_client: "microsoft",
986 }),
987 ],
988 tools: [
989 tool("unknownGraphThing", {
990 integration: "microsoft_graph_api",
991 plugin_id: "microsoft",
992 }),
993 ],
994 pluginStorage: [],
995 blobs: [
996 blob("spec/graph-hash", { namespace: "o:org_1/microsoft" }),
997 blob("defs/graph-hash", { namespace: "o:org_1/microsoft" }),
998 ],
999 }),
1000 ),
1001 ).toThrow(/tool row\(s\) with no target service: unknownGraphThing/);
1002 });
1003
1004 it("hard-errors when Microsoft config lists an unmigratable umbrella preset", () => {
1005 expect(() =>
1006 planMigration(
1007 input({
1008 integrations: [
1009 integration({
1010 plugin_id: "microsoft",
1011 slug: "microsoft_graph_api",
1012 name: "Microsoft Graph API",
1013 config: {
1014 microsoftGraphPresetIds: ["me-surface"],
1015 specHash: "graph-hash",
1016 },
1017 }),
1018 ],
1019 connections: [],
1020 tools: [],
1021 pluginStorage: [],
1022 }),
1023 ),
1024 ).toThrow(/unmigratable-umbrella Microsoft Graph preset\(s\): me-surface/);
1025 });
1026
750 it("fans Microsoft workbook policies to files and excel without widening", () => {1027 it("fans Microsoft workbook policies to files and excel without widening", () => {
751 const migrationInput = input({1028 const migrationInput = input({
752 integrations: [1029 integrations: [