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
337337 microsoftCatalog.map((preset) => [preset.id.replace(/^microsoft-/, ""), preset]),
338338 );
339339
340+const MICROSOFT_UNMIGRATABLE_UMBRELLA_PRESET_IDS = new Set(["me-surface", "users", "groups"]);
341+const MICROSOFT_TOOL_IMPLIED_PRESET_IDS = new Set(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS);
342+const microsoftCatalogBackedPresetIds = new Set(
343+ microsoftCatalog
344+ .filter((preset) => preset.defaultSlug && preset.defaultSlug.length > 0)
345+ .map((preset) => preset.id.replace(/^microsoft-/, "")),
346+);
347+
348+export 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+
438+const MICROSOFT_WORKBOOK_FIRST_SEGMENTS = new Set([
439+ "drivesDriveItem",
440+ "groupsDrive",
441+ "meDrive",
442+ "usersDrive",
443+]);
444+
445+const 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+
340455 const serviceSlugForPreset = (family: MonolithPluginId, presetId: string): string | undefined => {
341456 const preset =
342457 family === "google"
@@ -405,6 +520,13 @@ const microsoftPresetIdsFromConfig = (integration: IntegrationRow): readonly str · 62 unmodified lines
405520 );
406521 };
407522
523+const 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+
408530 export const googlePresetIdForTool = (toolName: string): string | undefined => {
409531 const [first, second] = toolName.split(".");
410532 if (first === "admin") {
@@ -413,38 +535,20 @@ export const googlePresetIdForTool = (toolName: string): string | undefined => { · 2 unmodified lines
413535 return GOOGLE_TOOL_PREFIX_TO_PRESET_ID.get(first ?? "");
414536 };
415537
416−const microsoftPresetIdsForTool = (toolName: string): readonly string[] => {
538+export const microsoftPresetIdsForTool = (toolName: string): readonly string[] => {
417539 const segments = toolName.split(".");
418540 const first = segments[0] ?? "";
419− const presetIds: string[] = [];
420− const add = (presetId: string) => {
421− if (!presetIds.includes(presetId)) presetIds.push(presetId);
422− };
423−
424− if (/^me/.test(first)) add("me-surface");
425− if (/^users/.test(first)) add("users");
426− if (/^groups/.test(first)) add("groups");
427− if (/^(me|users).*Message|^meMail|^usersMail|^meMailbox|^meOutlook|^usersOutlook/.test(first)) {
428− add("mail");
429− }
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;
541+ const ownershipKey = microsoftFirstSegmentOwnershipKey(first);
542+ const fromFirstSegment = ownershipKey
543+ ? (microsoftToolFirstSegmentPresetIds.get(ownershipKey) ?? [])
544+ : [];
545+ const workbookOwned =
546+ ownershipKey !== undefined &&
547+ MICROSOFT_WORKBOOK_FIRST_SEGMENTS.has(ownershipKey) &&
548+ segments.some((segment) => segment.toLowerCase().includes("workbook"));
549+ return unique([...fromFirstSegment, ...(workbookOwned ? ["excel"] : [])]).filter((presetId) =>
550+ MICROSOFT_TOOL_IMPLIED_PRESET_IDS.has(presetId),
551+ );
448552 };
449553
450554 const presetIdsForTool = (pluginId: MonolithPluginId, toolName: string): readonly string[] => {
@@ -462,6 +566,14 @@ const deriveServices = ( · 11 unmodified lines
462566 pluginId === "google"
463567 ? googlePresetIdsFromConfig(integration)
464568 : 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+ }
465577 const fromTools = unique(
466578 tools.flatMap((tool) => {
467579 const presetIds = presetIdsForTool(pluginId, tool.name);
@@ -578,7 +690,7 @@ const operationRowsForService = ( · 110 unmodified lines
578690 if (operationIntegration(row) !== monolith.slug) return false;
579691 const toolName = operationToolName(row);
580692 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;
582694 return serviceForMatchedTool(monolith.plugin_id as MonolithPluginId, toolName, [
583695 target,
584696 ]).includes(target.slug);
@@ -602,6 +714,9 @@ const isConfigOnlyMonolith = (input: { · 17 unmodified lines
602714 }): boolean =>
603715 input.tools.length === 0 && input.connections.length === 0 && input.operations.length === 0;
604716
717+const isIntentionallyDroppedTool = (pluginId: MonolithPluginId, toolName: string): boolean =>
718+ pluginId === "google" && toolName.startsWith("oauth2.");
719+
605720 const serviceForMatchedTool = (
606721 pluginId: MonolithPluginId,
607722 toolName: string,
@@ -617,6 +732,43 @@ const serviceForMatchedTool = ( · 9 unmodified lines
617732 const allServiceSlugs = (services: readonly ServiceTarget[]): readonly string[] =>
618733 services.map((service) => service.slug);
619734
735+const 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+
747+const 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+
620772 const serviceSlugsForToolPattern = (
621773 pluginId: MonolithPluginId,
622774 toolSegments: readonly string[],
@@ -880,6 +1032,13 @@ export const planMigration = (input: MigrationInput): MigrationPlan => { · 257 unmodified lines
8801032 ) {
8811033 continue;
8821034 }
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+ }
8831042 const specHash = specHashFor(monolith);
8841043 const namespace = pluginBlobNamespace(tenant, monolith.plugin_id);
8851044 const targetNamespace = pluginBlobNamespace(tenant, "openapi");
tests/migration/service-split-planner.test.ts+277−0
@@ -1,7 +1,14 @@
11 import { describe, expect, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+import {
4+ streamOperationBindingsFromStructure,
5+ structuralSplit,
6+} from "../../packages/plugins/openapi/src/sdk";
27
38 import {
49 googlePresetIdForTool,
10+ microsoftPresetIdsForTool,
11+ microsoftToolFirstSegmentPresetIds,
512 operationStorageKey,
613 planMigration,
714 verifyPolicyRewriteNeverWidens,
@@ -161,6 +168,56 @@ const googleCatalogMethodPrefixFixtures: ReadonlyMap<string, readonly string[]> · 153 unmodified lines
161168 ["google-cloud-resource-manager", ["cloudresourcemanager.projects.list"]],
162169 ]);
163170
171+const 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+
186+const microsoftFixtureTagForFirstSegment = (firstSegment: string): string =>
187+ firstSegment.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
188+
189+const 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
204+info:
205+ title: Microsoft Graph Tool Ownership Fixture
206+ version: v1.0
207+paths:
208+${paths}
209+components: {}
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+
164221 const input = (overrides: Partial<MigrationInput> = {}): MigrationInput => ({
165222 integrations: [integration()],
166223 connections: [connection()],
@@ -196,6 +253,23 @@ describe("provider service split migration planner", () => { · 29 unmodified lines
196253 }
197254 });
198255
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+
199273 it("plans a single-service monolith split", () => {
200274 const plan = planMigration(input());
201275
@@ -747,6 +821,209 @@ describe("provider service split migration planner", () => { · 545 unmodified lines
747821 expect(plan.summary.hardErrorOrgs).toBe(0);
748822 });
749823
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+
7501027 it("fans Microsoft workbook policies to files and excel without widening", () => {
7511028 const migrationInput = input({
7521029 integrations: [