@@ -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+
224237const 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+
623930describe ( "Microsoft Graph health-check default" , ( ) => {
624931 it . effect ( "addGraph without profile still auto-configures the /me identity check" , ( ) =>
625932 Effect . scoped (
0 commit comments