Skip to content

Commit 046ff96

Browse files
committed
Test Microsoft per-workload add flow
1 parent bc811ec commit 046ff96

1 file changed

Lines changed: 341 additions & 34 deletions

File tree

packages/plugins/microsoft/src/sdk/plugin.test.ts

Lines changed: 341 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ paths:
6767
responses:
6868
"200":
6969
description: OK
70+
/me/drive/root/children:
71+
get:
72+
operationId: me.drive.root.ListChildren
73+
responses:
74+
"200":
75+
description: OK
7076
/sites:
7177
get:
7278
operationId: sites.ListSites
@@ -185,42 +191,49 @@ components:
185191
https://graph.microsoft.com/.default: https://graph.microsoft.com/.default
186192
`;
187193

188-
const graphHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
189-
HttpClient.make((request: HttpClientRequest.HttpClientRequest) =>
190-
Effect.succeed(
191-
HttpClientResponse.fromWeb(
192-
request,
193-
new Response(
194-
request.url === MICROSOFT_GRAPH_OPENAPI_URL
195-
? graphFixture
196-
: request.url === MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL
197-
? permissionsReferenceFixture
198-
: request.url === EMULATOR_SPEC_URL
199-
? emulatorGraphFixture
200-
: request.url === LOCAL_EMULATOR_SPEC_URL
201-
? localEmulatorGraphFixture
202-
: "not found",
203-
{
204-
status:
205-
request.url === MICROSOFT_GRAPH_OPENAPI_URL ||
206-
request.url === MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL ||
207-
request.url === EMULATOR_SPEC_URL ||
208-
request.url === LOCAL_EMULATOR_SPEC_URL
209-
? 200
210-
: 404,
211-
headers: {
212-
"content-type":
213-
request.url === MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL
214-
? "text/markdown"
215-
: "application/yaml",
216-
},
217-
},
218-
),
219-
),
220-
),
221-
),
194+
interface GraphHttpClientOptions {
195+
readonly failedUrls?: ReadonlySet<string>;
196+
readonly beforeResponse?: (url: string) => Effect.Effect<void, never, never>;
197+
}
198+
199+
const waitOneTurn: Effect.Effect<void, never, never> = Effect.promise(() => Promise.resolve()).pipe(
200+
Effect.orDie,
222201
);
223202

203+
const GRAPH_FIXTURE_BODIES: Readonly<Record<string, string>> = {
204+
[MICROSOFT_GRAPH_OPENAPI_URL]: graphFixture,
205+
[MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL]: permissionsReferenceFixture,
206+
[EMULATOR_SPEC_URL]: emulatorGraphFixture,
207+
[LOCAL_EMULATOR_SPEC_URL]: localEmulatorGraphFixture,
208+
};
209+
210+
const makeGraphHttpClientLayer = (options: GraphHttpClientOptions = {}) =>
211+
Layer.succeed(HttpClient.HttpClient)(
212+
HttpClient.make((request: HttpClientRequest.HttpClientRequest) => {
213+
const body = GRAPH_FIXTURE_BODIES[request.url];
214+
const failed = options.failedUrls?.has(request.url) === true;
215+
const response = HttpClientResponse.fromWeb(
216+
request,
217+
failed
218+
? new Response("forced failure", { status: 503 })
219+
: body === undefined
220+
? new Response("not found", { status: 404 })
221+
: new Response(body, {
222+
status: 200,
223+
headers: {
224+
"content-type":
225+
request.url === MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL
226+
? "text/markdown"
227+
: "application/yaml",
228+
},
229+
}),
230+
);
231+
return (options.beforeResponse?.(request.url) ?? Effect.void).pipe(Effect.as(response));
232+
}),
233+
);
234+
235+
const graphHttpClientLayer = makeGraphHttpClientLayer();
236+
224237
const graphPlugins = (options?: Omit<MicrosoftPluginOptions, "httpClientLayer">) =>
225238
[
226239
microsoftPlugin({ httpClientLayer: graphHttpClientLayer, ...options }),
@@ -620,6 +633,300 @@ describe("Microsoft Graph provider", () => {
620633
);
621634
});
622635

636+
describe("Microsoft Graph per-workload add flow", () => {
637+
const workloadExpectations = [
638+
{
639+
presetId: "mail",
640+
slug: "microsoft_mail",
641+
name: "Outlook Mail",
642+
description: "Messages, folders, attachments, settings, and send mail.",
643+
scopes: ["Mail.ReadWrite", "Mail.Send", "MailboxSettings.ReadWrite"],
644+
// Every per-workload source seeds the bare GET /me identity health check
645+
// (`me.getUser`) alongside its own workload operations.
646+
toolNames: ["me.getUser", "me.messagesListMessages"],
647+
absentToolNames: ["me.eventsListEvents", "me.driveRootListChildren", "sites.listSites"],
648+
},
649+
{
650+
presetId: "calendar",
651+
slug: "microsoft_calendar",
652+
name: "Outlook Calendar",
653+
description: "Calendars, events, and scheduling.",
654+
scopes: ["Calendars.ReadWrite"],
655+
toolNames: ["me.getUser", "me.eventsListEvents"],
656+
absentToolNames: ["me.messagesListMessages", "me.driveRootListChildren", "sites.listSites"],
657+
},
658+
{
659+
presetId: "files",
660+
slug: "microsoft_files",
661+
name: "OneDrive Files",
662+
description: "Drives, files, folders, sharing links, and permissions.",
663+
scopes: ["Files.ReadWrite.All", "Sites.ReadWrite.All"],
664+
toolNames: ["me.driveRootListChildren", "me.getUser"],
665+
absentToolNames: ["me.messagesListMessages", "me.eventsListEvents", "sites.listSites"],
666+
},
667+
] as const;
668+
669+
it.effect(
670+
"addWorkloads creates one integration per preset with exact scopes and health checks",
671+
() =>
672+
Effect.scoped(
673+
Effect.gen(function* () {
674+
const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() }));
675+
const connectedToolNames = (slug: string) =>
676+
Effect.gen(function* () {
677+
yield* executor.connections.create({
678+
owner: "org",
679+
name: ConnectionName.make(slug),
680+
integration: IntegrationSlug.make(slug),
681+
template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG),
682+
value: "token-xyz",
683+
});
684+
return (yield* executor.tools.list({ integration: IntegrationSlug.make(slug) }))
685+
.map((tool) => String(tool.name))
686+
.sort();
687+
});
688+
689+
const result = yield* executor.microsoft.addWorkloads({
690+
workloads: workloadExpectations.map((workload) => ({ presetId: workload.presetId })),
691+
});
692+
693+
expect(result).toEqual({
694+
added: workloadExpectations.map((workload) => ({
695+
slug: IntegrationSlug.make(workload.slug),
696+
presetId: workload.presetId,
697+
toolCount: 2,
698+
})),
699+
skipped: [],
700+
failed: [],
701+
});
702+
703+
const integrations = yield* executor.integrations.list();
704+
for (const workload of workloadExpectations) {
705+
const integration = integrations.find((item) => String(item.slug) === workload.slug);
706+
expect(integration?.name).toBe(workload.name);
707+
expect(integration?.description).toBe(workload.description);
708+
709+
const config = yield* executor.microsoft.getConfig(workload.slug);
710+
expect(config?.microsoftGraphPresetIds).toEqual([workload.presetId]);
711+
expect(config?.microsoftGraphCoversFullGraph).toBe(false);
712+
// Each workload's scopes are exactly its preset scopes plus the base
713+
// and identity (User.Read) scopes, never another workload's.
714+
expect(config?.microsoftGraphScopes).toEqual([
715+
"offline_access",
716+
"User.Read",
717+
...workload.scopes,
718+
]);
719+
720+
const delegated = config?.authenticationTemplate?.find(
721+
(entry) => String(entry.slug) === MICROSOFT_AUTH_TEMPLATE_SLUG,
722+
);
723+
expect(delegated?.kind === "oauth2" ? delegated.scopes : undefined).toEqual([
724+
"offline_access",
725+
"User.Read",
726+
...workload.scopes,
727+
]);
728+
729+
// The bare GET /me identity check is seeded per workload source.
730+
const stored = yield* executor.integrations.healthCheck.get(
731+
IntegrationSlug.make(workload.slug),
732+
);
733+
expect(stored?.operation).toBe("me.getUser");
734+
expect(stored?.identityField).toBe("userPrincipalName");
735+
736+
const toolNames = yield* connectedToolNames(workload.slug);
737+
expect(toolNames).toEqual([...workload.toolNames].sort());
738+
for (const absent of workload.absentToolNames) {
739+
expect(toolNames).not.toContain(absent);
740+
}
741+
}
742+
}),
743+
),
744+
);
745+
746+
it.effect("addWorkloads isolates a spec-fetch failure and keeps other workloads registered", () =>
747+
Effect.scoped(
748+
Effect.gen(function* () {
749+
// Force every Graph spec fetch to fail. The middle workload's failure
750+
// must not poison the workloads that would otherwise succeed: a shared
751+
// 37MB source is fetched per add, so an isolated 503 during the mail
752+
// add is reported in `failed` while calendar and files still land.
753+
const failingLayer = makeGraphHttpClientLayer({
754+
failedUrls: new Set([MICROSOFT_GRAPH_OPENAPI_URL]),
755+
});
756+
const executor = yield* createExecutor(
757+
makeTestConfig({
758+
plugins: [
759+
microsoftPlugin({ httpClientLayer: failingLayer }),
760+
memoryCredentialsPlugin(),
761+
],
762+
}),
763+
);
764+
765+
const result = yield* executor.microsoft.addWorkloads({
766+
workloads: [{ presetId: "mail" }, { presetId: "calendar" }, { presetId: "files" }],
767+
});
768+
769+
expect(result).toEqual({
770+
added: [],
771+
skipped: [],
772+
failed: [
773+
{
774+
slug: IntegrationSlug.make("microsoft_mail"),
775+
presetId: "mail",
776+
error: "Failed to fetch Microsoft Graph OpenAPI document: HTTP 503",
777+
},
778+
{
779+
slug: IntegrationSlug.make("microsoft_calendar"),
780+
presetId: "calendar",
781+
error: "Failed to fetch Microsoft Graph OpenAPI document: HTTP 503",
782+
},
783+
{
784+
slug: IntegrationSlug.make("microsoft_files"),
785+
presetId: "files",
786+
error: "Failed to fetch Microsoft Graph OpenAPI document: HTTP 503",
787+
},
788+
],
789+
});
790+
791+
expect(yield* executor.microsoft.getIntegration("microsoft_mail")).toBeNull();
792+
}),
793+
),
794+
);
795+
796+
it.effect("addWorkloads isolates a single failing workload from the ones that succeed", () =>
797+
Effect.scoped(
798+
Effect.gen(function* () {
799+
const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() }));
800+
const connectedToolNames = (slug: string) =>
801+
Effect.gen(function* () {
802+
yield* executor.connections.create({
803+
owner: "org",
804+
name: ConnectionName.make(slug),
805+
integration: IntegrationSlug.make(slug),
806+
template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG),
807+
value: "token-xyz",
808+
});
809+
return (yield* executor.tools.list({ integration: IntegrationSlug.make(slug) }))
810+
.map((tool) => String(tool.name))
811+
.sort();
812+
});
813+
814+
// An unknown preset fails with a parse error mid-list; the workloads on
815+
// either side of it still register.
816+
const result = yield* executor.microsoft.addWorkloads({
817+
workloads: [
818+
{ presetId: "mail" },
819+
{ presetId: "not-a-real-workload" },
820+
{ presetId: "files" },
821+
],
822+
});
823+
824+
expect(result.added).toEqual([
825+
{
826+
slug: IntegrationSlug.make("microsoft_mail"),
827+
presetId: "mail",
828+
toolCount: 2,
829+
},
830+
{
831+
slug: IntegrationSlug.make("microsoft_files"),
832+
presetId: "files",
833+
toolCount: 2,
834+
},
835+
]);
836+
expect(result.skipped).toEqual([]);
837+
expect(result.failed).toEqual([
838+
{
839+
slug: IntegrationSlug.make("microsoft_not_a_real_workload"),
840+
presetId: "not-a-real-workload",
841+
error: "Microsoft Graph workload preset is not available: not-a-real-workload",
842+
},
843+
]);
844+
845+
expect(yield* connectedToolNames("microsoft_mail")).toEqual(
846+
["me.getUser", "me.messagesListMessages"].sort(),
847+
);
848+
expect(yield* connectedToolNames("microsoft_files")).toEqual(
849+
["me.driveRootListChildren", "me.getUser"].sort(),
850+
);
851+
}),
852+
),
853+
);
854+
855+
it.effect("addWorkloads skips an existing workload without duplicating integration rows", () =>
856+
Effect.scoped(
857+
Effect.gen(function* () {
858+
const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() }));
859+
860+
yield* executor.microsoft.addWorkloads({ workloads: [{ presetId: "mail" }] });
861+
const result = yield* executor.microsoft.addWorkloads({
862+
workloads: [{ presetId: "mail" }],
863+
});
864+
865+
expect(result).toEqual({
866+
added: [],
867+
skipped: [
868+
{
869+
slug: IntegrationSlug.make("microsoft_mail"),
870+
presetId: "mail",
871+
reason: "already_exists",
872+
},
873+
],
874+
failed: [],
875+
});
876+
877+
const integrations = yield* executor.integrations.list();
878+
expect(
879+
integrations.filter((integration) => String(integration.slug) === "microsoft_mail"),
880+
).toHaveLength(1);
881+
}),
882+
),
883+
);
884+
885+
it.effect("addWorkloads starts each workload spec fetch sequentially", () =>
886+
Effect.scoped(
887+
Effect.gen(function* () {
888+
// Each per-workload add fetches the (37MB in prod) Graph source; two in
889+
// flight recreate the isolate OOM. The strict {concurrency: 1} guard is
890+
// proven by a spy that counts in-flight spec fetches: it would observe
891+
// maxActive === 2 under concurrency 2, and never settles a fetch until
892+
// the next microtask turn so any overlap is caught.
893+
let activeSpecFetches = 0;
894+
let maxActiveSpecFetches = 0;
895+
let specFetchCount = 0;
896+
const sequentialLayer = makeGraphHttpClientLayer({
897+
beforeResponse: (url) => {
898+
if (url !== MICROSOFT_GRAPH_OPENAPI_URL) return Effect.void;
899+
return Effect.gen(function* () {
900+
activeSpecFetches += 1;
901+
specFetchCount += 1;
902+
maxActiveSpecFetches = Math.max(maxActiveSpecFetches, activeSpecFetches);
903+
yield* waitOneTurn;
904+
activeSpecFetches -= 1;
905+
});
906+
},
907+
});
908+
const executor = yield* createExecutor(
909+
makeTestConfig({
910+
plugins: [
911+
microsoftPlugin({ httpClientLayer: sequentialLayer }),
912+
memoryCredentialsPlugin(),
913+
],
914+
}),
915+
);
916+
917+
const result = yield* executor.microsoft.addWorkloads({
918+
workloads: [{ presetId: "mail" }, { presetId: "calendar" }, { presetId: "files" }],
919+
});
920+
921+
expect(result.failed).toEqual([]);
922+
expect(result.added).toHaveLength(3);
923+
expect(specFetchCount).toBe(3);
924+
expect(maxActiveSpecFetches).toBe(1);
925+
}),
926+
),
927+
);
928+
});
929+
623930
describe("Microsoft Graph health-check default", () => {
624931
it.effect("addGraph without profile still auto-configures the /me identity check", () =>
625932
Effect.scoped(

0 commit comments

Comments
 (0)