From 60e0cb1e7be9be7e2c15bbeabae8d59cc139dc22 Mon Sep 17 00:00:00 2001 From: Bernhard Wittmann Date: Tue, 13 Oct 2026 13:01:48 +0000 Subject: [PATCH] feat(core): Publish the Microsoft Teams app to the organisation (no-changelog) (#40697) Co-authored-by: Claude Opus 5 (1M context) --- packages/@n8n/api-types/src/agents/dto.ts | 6 + .../@n8n/api-types/src/agents/teams/types.ts | 18 + ...gent-teams-integrations.controller.test.ts | 165 +++++- .../agent-teams-integrations.controller.ts | 74 +++ .../__tests__/teams-catalog.service.test.ts | 559 ++++++++++++++++++ .../teams-entra-provisioning.service.test.ts | 71 +++ .../teams-managed-setup.service.test.ts | 18 + .../__tests__/teams-setup.service.test.ts | 51 ++ .../platforms/teams/teams-catalog.service.ts | 459 ++++++++++++++ .../teams/teams-entra-provisioning.service.ts | 28 + .../platforms/teams/teams-graph.service.ts | 34 ++ .../teams/teams-managed-setup.service.ts | 2 + .../teams/teams-manager-token.service.ts | 2 + .../teams/teams-setup-telemetry.service.ts | 8 +- .../platforms/teams/teams-setup.service.ts | 6 +- .../frontend/@n8n/i18n/src/locales/en.json | 30 +- .../channels/AgentChannelStandardEditView.vue | 2 + .../teams/AgentChannelTeamsEditView.test.ts | 269 ++++++++- .../teams/AgentChannelTeamsEditView.vue | 5 +- .../teams/AgentChannelTeamsManagedApp.test.ts | 285 +++++++++ .../teams/AgentChannelTeamsManagedApp.vue | 230 +++++++ .../AgentChannelTeamsManagedSetup.test.ts | 261 +++++++- .../teams/AgentChannelTeamsManagedSetup.vue | 268 +++++++-- .../teams/AgentChannelTeamsSetup.test.ts | 12 +- .../channels/teams/AgentChannelTeamsSetup.vue | 177 ++++-- .../teams/AgentChannelTeamsSetupView.test.ts | 5 + .../agents/channels/teams/api.test.ts | 39 ++ .../src/features/agents/channels/teams/api.ts | 31 + .../teams/useTeamsChannelRuntime.test.ts | 123 ++++ .../channels/teams/useTeamsChannelRuntime.ts | 87 ++- .../src/features/agents/channels/types.ts | 6 + .../agents/components/AgentChannelModal.vue | 1 + ...rosoftTeamsManagerOAuth2Api.credentials.ts | 11 +- ...tTeamsManagerOAuth2Api.credentials.test.ts | 9 +- 34 files changed, 3241 insertions(+), 111 deletions(-) create mode 100644 packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-catalog.service.test.ts create mode 100644 packages/cli/src/modules/agents/integrations/platforms/teams/teams-catalog.service.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsManagedApp.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsManagedApp.vue diff --git a/packages/@n8n/api-types/src/agents/dto.ts b/packages/@n8n/api-types/src/agents/dto.ts index 1c7d940a56f..15770b656da 100644 --- a/packages/@n8n/api-types/src/agents/dto.ts +++ b/packages/@n8n/api-types/src/agents/dto.ts @@ -395,6 +395,12 @@ export class AgentTeamsProvisionBotDto extends Z.class({ subscriptionId: microsoftId, }) {} +export class AgentTeamsPublishDto extends Z.class({ + managerCredentialId: z.string().min(1), + credentialId: z.string().min(1), + settings: AgentTeamsSettingsSchema.optional(), +}) {} + export class AgentDisconnectIntegrationDto extends Z.class({ type: z.string().min(1), // Empty string targets a draft integration entry (`credentialId: ''`). diff --git a/packages/@n8n/api-types/src/agents/teams/types.ts b/packages/@n8n/api-types/src/agents/teams/types.ts index 37587387285..9519c350622 100644 --- a/packages/@n8n/api-types/src/agents/teams/types.ts +++ b/packages/@n8n/api-types/src/agents/teams/types.ts @@ -74,6 +74,12 @@ export interface TeamsAgentSetupState { */ defaultDisplayName: string; defaultDescription: string; + /** + * Whether the connected credential is one the recommended setup made for + * this agent. A channel wired up by hand is left to the manual flow even + * where the recommended one could have run. + */ + provisionedByN8n: boolean; } /** @@ -159,3 +165,15 @@ export interface TeamsProvisionedBotSummary { resourceGroup: string; subscriptionId: string; } + +/** + * Where the Teams app stands in the organisation's catalogue, read back from + * Microsoft rather than tracked by n8n. + * + * `unknown` covers an app that is not listed yet: published policies take a day + * or two to apply, so an app missing from the listing has not been refused. + */ +export interface TeamsCatalogState { + status: 'published' | 'submitted' | 'rejected' | 'unknown'; + teamsAppId: string | null; +} diff --git a/packages/cli/src/modules/agents/__tests__/agent-teams-integrations.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agent-teams-integrations.controller.test.ts index 011d8928db5..d72caf50cb6 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-teams-integrations.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-teams-integrations.controller.test.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */ -import type { TeamsAzureSubscription } from '@n8n/api-types'; +import { AgentTeamsPublishDto } from '@n8n/api-types'; +import type { TeamsAzureSubscription, TeamsCatalogState } from '@n8n/api-types'; import type { User } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; import type { Request, Response } from 'express'; @@ -13,6 +14,7 @@ import { } from './test-utils/controller-route-metadata'; import type { TeamsCredentialCheckService } from '../integrations/platforms/teams/teams-credential-check.service'; import type { TeamsBotProvisioningService } from '../integrations/platforms/teams/teams-bot-provisioning.service'; +import type { TeamsCatalogService } from '../integrations/platforms/teams/teams-catalog.service'; import type { TeamsEntraProvisioningService } from '../integrations/platforms/teams/teams-entra-provisioning.service'; import type { TeamsSetupTelemetryService } from '../integrations/platforms/teams/teams-setup-telemetry.service'; import type { TeamsManagedSetupService } from '../integrations/platforms/teams/teams-managed-setup.service'; @@ -40,6 +42,8 @@ describe('AgentTeamsIntegrationsController', () => { ['getManagedSetupState', 'agent:update'], ['createManagerCredential', 'agent:update'], ['reportManualBotRoute', 'agent:update'], + ['publishApp', 'agent:update'], + ['getCatalogState', 'agent:update'], ])('%s uses %s', (handlerName, scope) => { expect(routes.get(handlerName)?.accessScope?.scope).toBe(scope); }); @@ -56,6 +60,7 @@ describe('AgentTeamsIntegrationsController', () => { mock(), mock(), mock(), + mock(), mock(), ), }; @@ -144,6 +149,7 @@ describe('AgentTeamsIntegrationsController', () => { mock(), mock(), botProvisioningService, + mock(), setupTelemetry, ), }; @@ -191,4 +197,161 @@ describe('AgentTeamsIntegrationsController', () => { }); }); }); + /** + * The publish call is where the two routes Microsoft offers are told apart, + * and the route is what the ladder reports. The service answers four states + * and only two of them are routes. + */ + describe('the catalogue publish', () => { + const buildController = () => { + const catalogService = mock(); + const setupTelemetry = mock(); + return { + catalogService, + setupTelemetry, + controller: new AgentTeamsIntegrationsController( + mock(), + mock(), + mock(), + mock(), + mock(), + catalogService, + setupTelemetry, + ), + }; + }; + + const request = () => + mock>({ + params: { projectId: 'project-1' }, + user: mock({ id: 'user-1' }), + }); + + const payload = () => + new AgentTeamsPublishDto({ + managerCredentialId: 'manager-1', + credentialId: 'cred-1', + settings: { displayName: 'Support Bot' }, + }); + + it('publishes with the credentials and settings the caller named', async () => { + const { controller, catalogService } = buildController(); + catalogService.publish.mockResolvedValue({ status: 'published', teamsAppId: 'app-1' }); + + await expect( + controller.publishApp(request(), mock(), 'agent-1', payload()), + ).resolves.toEqual({ status: 'published', teamsAppId: 'app-1' }); + + expect(catalogService.publish).toHaveBeenCalledWith({ + user: expect.objectContaining({ id: 'user-1' }), + projectId: 'project-1', + agentId: 'agent-1', + managerCredentialId: 'manager-1', + credentialId: 'cred-1', + settings: { displayName: 'Support Bot' }, + }); + }); + + it.each(['published', 'submitted'] as const)( + 'records %s as the install route', + async (status) => { + const { controller, catalogService, setupTelemetry } = buildController(); + catalogService.publish.mockResolvedValue({ status, teamsAppId: 'app-1' }); + + await controller.publishApp(request(), mock(), 'agent-1', payload()); + + expect(setupTelemetry.succeeded).toHaveBeenCalledWith({ + agentId: 'agent-1', + projectId: 'project-1', + userId: 'user-1', + step: 'install', + installRoute: status, + }); + }, + ); + + // Neither answer took a route: the app is not listed yet, or Microsoft + // refused it. Reporting one here would claim an install that never ran. + it.each(['unknown', 'rejected'] as const)('records no install route for %s', async (status) => { + const { controller, catalogService, setupTelemetry } = buildController(); + catalogService.publish.mockResolvedValue({ status, teamsAppId: null }); + + await controller.publishApp(request(), mock(), 'agent-1', payload()); + + expect(setupTelemetry.succeeded).toHaveBeenCalledWith( + expect.not.objectContaining({ installRoute: expect.anything() }), + ); + }); + + it('records a failed publish and still fails the request', async () => { + const { controller, catalogService, setupTelemetry } = buildController(); + const error = new Error('Microsoft refused the upload'); + catalogService.publish.mockRejectedValue(error); + + await expect( + controller.publishApp(request(), mock(), 'agent-1', payload()), + ).rejects.toThrow(error); + + expect(setupTelemetry.failed).toHaveBeenCalledWith( + { agentId: 'agent-1', projectId: 'project-1', userId: 'user-1', step: 'install' }, + error, + ); + }); + it('reads the catalogue state for the credentials the caller named', async () => { + const { controller, catalogService } = buildController(); + const state: TeamsCatalogState = { status: 'submitted', teamsAppId: 'app-1' }; + catalogService.getState.mockResolvedValue(state); + + const req = mock< + AuthenticatedRequest< + { projectId: string }, + {}, + {}, + { managerCredentialId?: string; credentialId?: string } + > + >({ + params: { projectId: 'project-1' }, + query: { managerCredentialId: 'manager-1', credentialId: 'cred-1' }, + user: mock({ id: 'user-1' }), + }); + + await expect(controller.getCatalogState(req, mock(), 'agent-1')).resolves.toBe( + state, + ); + + expect(catalogService.getState).toHaveBeenCalledWith({ + user: expect.objectContaining({ id: 'user-1' }), + projectId: 'project-1', + agentId: 'agent-1', + managerCredentialId: 'manager-1', + credentialId: 'cred-1', + }); + }); + + // The setup asks for the state before the user signs in, and without a + // manager credential there is nothing to ask Microsoft with. + it('answers unknown without reaching Microsoft when no manager credential is named', async () => { + const { controller, catalogService } = buildController(); + + const req = mock< + AuthenticatedRequest< + { projectId: string }, + {}, + {}, + { managerCredentialId?: string; credentialId?: string } + > + >({ + params: { projectId: 'project-1' }, + query: {}, + user: mock({ id: 'user-1' }), + }); + + await expect(controller.getCatalogState(req, mock(), 'agent-1')).resolves.toEqual({ + status: 'unknown', + teamsAppId: null, + }); + + expect(catalogService.getState).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/modules/agents/agent-teams-integrations.controller.ts b/packages/cli/src/modules/agents/agent-teams-integrations.controller.ts index 28f483fe0ff..c7b2ebe202b 100644 --- a/packages/cli/src/modules/agents/agent-teams-integrations.controller.ts +++ b/packages/cli/src/modules/agents/agent-teams-integrations.controller.ts @@ -2,6 +2,7 @@ import { AgentTeamsPackageDto, AgentTeamsProvisionAppDto, AgentTeamsProvisionBotDto, + AgentTeamsPublishDto, } from '@n8n/api-types'; import type { CreateTeamsManagerCredentialResponse, @@ -9,6 +10,7 @@ import type { TeamsCredentialCheck, TeamsManagedSetupState, TeamsAzureSubscription, + TeamsCatalogState, TeamsProvisionedAppSummary, TeamsProvisionedBotSummary, } from '@n8n/api-types'; @@ -21,6 +23,7 @@ import type { Request, Response } from 'express'; import { TeamsCredentialCheckService } from './integrations/platforms/teams/teams-credential-check.service'; import { TeamsBotProvisioningService } from './integrations/platforms/teams/teams-bot-provisioning.service'; +import { TeamsCatalogService } from './integrations/platforms/teams/teams-catalog.service'; import { TeamsEntraProvisioningService } from './integrations/platforms/teams/teams-entra-provisioning.service'; import { TeamsSetupTelemetryService } from './integrations/platforms/teams/teams-setup-telemetry.service'; import { TeamsManagedSetupService } from './integrations/platforms/teams/teams-managed-setup.service'; @@ -46,9 +49,80 @@ export class AgentTeamsIntegrationsController { private readonly managedSetupService: TeamsManagedSetupService, private readonly entraProvisioningService: TeamsEntraProvisioningService, private readonly botProvisioningService: TeamsBotProvisioningService, + private readonly catalogService: TeamsCatalogService, private readonly setupTelemetry: TeamsSetupTelemetryService, ) {} + /** + * Publishes the app to the organisation, or submits it for review when the + * account may not publish directly. Which of the two happened is in the + * state this returns. + */ + @Post('/:agentId/integrations/teams/publish') + @ProjectScope('agent:update') + async publishApp( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + @Body payload: AgentTeamsPublishDto, + ): Promise { + const report = { + agentId, + projectId: req.params.projectId, + userId: req.user.id, + step: 'install' as const, + }; + try { + const state = await this.catalogService.publish({ + user: req.user, + projectId: req.params.projectId, + agentId, + managerCredentialId: payload.managerCredentialId, + credentialId: payload.credentialId, + settings: payload.settings, + }); + // Which route the account actually got is the answer the publish call + // gives back. Only two of its four answers are routes, though: an app + // Microsoft has not listed yet, or has rejected, took neither. + this.setupTelemetry.succeeded({ + ...report, + ...(state.status === 'published' || state.status === 'submitted' + ? { installRoute: state.status } + : {}), + }); + return state; + } catch (error) { + this.setupTelemetry.failed(report, error); + throw error; + } + } + + @Get('/:agentId/integrations/teams/catalog') + @ProjectScope('agent:update') + async getCatalogState( + req: AuthenticatedRequest< + { projectId: string }, + {}, + {}, + { managerCredentialId?: string; credentialId?: string } + >, + _res: Response, + @Param('agentId') agentId: string, + ): Promise { + const managerCredentialId = req.query.managerCredentialId; + if (typeof managerCredentialId !== 'string' || managerCredentialId.length === 0) { + return { status: 'unknown', teamsAppId: null }; + } + const credentialId = req.query.credentialId; + return await this.catalogService.getState({ + user: req.user, + projectId: req.params.projectId, + agentId, + managerCredentialId, + ...(typeof credentialId === 'string' && credentialId.length > 0 ? { credentialId } : {}), + }); + } + /** * An empty list is the ordinary answer for a Microsoft 365 tenant, which * comes with no Azure subscription. The setup then offers the next option diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-catalog.service.test.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-catalog.service.test.ts new file mode 100644 index 00000000000..87b58d42abc --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-catalog.service.test.ts @@ -0,0 +1,559 @@ +import type { Logger } from '@n8n/backend-common'; +import type { CredentialsEntity, User } from '@n8n/db'; +import { mock } from 'vitest-mock-extended'; + +import type { CredentialsFinderService } from '@n8n/backend-services'; +import { BadRequestError } from '@n8n/errors'; + +import type { CredentialsService } from '@/credentials/credentials.service'; + +import { TeamsCatalogService } from '../teams-catalog.service'; +import type { GraphResponse, TeamsGraphService } from '../teams-graph.service'; +import type { TeamsManagerTokenService } from '../teams-manager-token.service'; +import type { TeamsManifestService } from '../teams-manifest.service'; +import type { TeamsSetupService } from '../teams-setup.service'; + +const user = mock({ id: 'user-1' }); +const EXTERNAL_ID = 'external-1'; +const TEAMS_APP_ID = 'teams-app-1'; +const ARCHIVE = Buffer.from('zip'); + +const scope = { + user, + projectId: 'project-1', + agentId: 'agent-1', + managerCredentialId: 'manager-1', +}; +const publishOptions = { ...scope, credentialId: 'bot-cred-1' }; + +const ok = (body: unknown = {}): GraphResponse => ({ statusCode: 200, body, ok: true }); +const failed = (statusCode: number, code?: string, message?: string): GraphResponse => ({ + statusCode, + body: code ? { error: { code, ...(message ? { message } : {}) } } : {}, + ok: false, +}); + +/** The catalogue listing Graph answers with, for a given review state. */ +const catalogListing = (publishingState: string | null) => + ok({ + value: [ + { + id: TEAMS_APP_ID, + externalId: EXTERNAL_ID, + appDefinitions: publishingState ? [{ publishingState }] : [], + }, + ], + }); + +describe('TeamsCatalogService', () => { + let graph: ReturnType>; + let tokens: ReturnType>; + let setupService: ReturnType>; + let credentialsFinderService: ReturnType>; + let credentialsService: ReturnType>; + let service: TeamsCatalogService; + + /** Makes the channel credential readable, carrying `data`. */ + const withBotCredential = (data: Record) => { + credentialsFinderService.findCredentialForUser.mockImplementation((async (id: string) => + mock({ + id, + type: + id === 'bot-cred-1' + ? 'microsoftEntraServicePrincipalApi' + : 'microsoftTeamsManagerOAuth2Api', + })) as never); + credentialsService.decrypt.mockResolvedValue(data as never); + credentialsService.createEncryptedData.mockResolvedValue({ data: 'encrypted' } as never); + }; + + /** No app in the catalogue yet, unless a test says otherwise. */ + const withCatalog = (response: GraphResponse = ok({ value: [] })) => { + graph.request.mockImplementation(async (_token: string, _method: string, path: string) => { + if (path.startsWith('/appCatalogs/teamsApps?')) return response; + return ok(); + }); + }; + + const zipCallTo = (fragment: string) => + graph.postZip.mock.calls.find(([, path]) => String(path).includes(fragment)); + + const zipCallToExactly = (path: string) => + graph.postZip.mock.calls.find(([, called]) => String(called) === path); + + afterEach(() => { + vi.useRealTimers(); + }); + + beforeEach(() => { + graph = mock(); + tokens = mock(); + setupService = mock(); + credentialsFinderService = mock(); + credentialsService = mock(); + const manifestService = mock(); + manifestService.buildManifestId.mockReturnValue(EXTERNAL_ID); + + service = new TeamsCatalogService( + graph, + tokens, + setupService, + manifestService, + credentialsFinderService, + credentialsService, + mock(), + ); + + tokens.acquire.mockResolvedValue('graph-token'); + setupService.buildPackage.mockResolvedValue(ARCHIVE); + credentialsFinderService.findCredentialForUser.mockResolvedValue( + mock({ + id: 'manager-1', + type: 'microsoftTeamsManagerOAuth2Api', + }) as never, + ); + graph.postZip.mockResolvedValue(ok({ id: TEAMS_APP_ID })); + withCatalog(); + }); + + describe('publish', () => { + it('publishes straight to the organisation for a Teams administrator', async () => { + graph.postZip.mockResolvedValue(ok({ id: TEAMS_APP_ID })); + let listed = false; + graph.request.mockImplementation(async (_t: string, _m: string, path: string) => { + if (!path.startsWith('/appCatalogs/teamsApps?')) return ok(); + const response = listed ? catalogListing('published') : ok({ value: [] }); + listed = true; + return response; + }); + + await expect(service.publish(publishOptions)).resolves.toEqual({ + status: 'published', + teamsAppId: TEAMS_APP_ID, + }); + expect(zipCallTo('requiresReview')).toBeUndefined(); + }); + + it('submits for review when the account may not publish directly', async () => { + let attempt = 0; + graph.postZip.mockImplementation(async () => { + attempt += 1; + return attempt === 1 ? failed(403, 'Forbidden') : ok({ id: TEAMS_APP_ID }); + }); + // Empty before the publish, listed after it — otherwise the service + // takes the update path and never tries a first publish at all. + let listed = false; + graph.request.mockImplementation(async (_t: string, _m: string, path: string) => { + if (!path.startsWith('/appCatalogs/teamsApps?')) return ok(); + const response = listed ? catalogListing('submitted') : ok({ value: [] }); + listed = true; + return response; + }); + + await expect(service.publish(publishOptions)).resolves.toEqual({ + status: 'submitted', + teamsAppId: TEAMS_APP_ID, + }); + expect(zipCallTo('requiresReview=true')).toBeDefined(); + }); + + it('decides the capability from the refusal, not from asking the user', async () => { + await service.publish(publishOptions); + + // The direct attempt always goes first; nothing asks what role the + // signed-in account holds. + expect(graph.postZip.mock.calls[0][1]).toBe('/appCatalogs/teamsApps'); + }); + + it('does not retry for review when the refusal was not about permission', async () => { + graph.postZip.mockResolvedValue(failed(500)); + + await expect(service.publish(publishOptions)).rejects.toThrow( + /would not accept the Teams app/, + ); + expect(graph.postZip).toHaveBeenCalledTimes(1); + }); + + /** + * Microsoft's wording for a rejected package carries request ids and + * backend host names. The log gets it; the user gets something readable. + */ + it('keeps Microsoft internals out of the message the user sees', async () => { + graph.postZip.mockResolvedValue({ + statusCode: 400, + ok: false, + body: { + error: { + code: 'BadRequest', + message: "Failed to parse manifest: 'name.short' is required.", + innerError: { code: 'UnableToParseTeamsAppManifest' }, + }, + }, + }); + + await expect(service.publish(publishOptions)).rejects.toThrow( + /Microsoft would not accept the Teams app/, + ); + await expect(service.publish(publishOptions)).rejects.not.toThrow(/Request Url/); + }); + + /** + * A taken name means a publish already landed, most likely a first attempt + * whose result never came back. Finishing it beats reporting a clash the + * user cannot act on. + */ + it('updates the app it already published when the name is taken', async () => { + // Empty when the publish starts, which is why it creates; present by the + // time the clash sends it looking. + let listed = false; + graph.request.mockImplementation(async (_token: string, _method: string, path: string) => { + if (path.startsWith('/appCatalogs/teamsApps?')) { + const value = listed + ? [{ id: TEAMS_APP_ID, appDefinitions: [{ publishingState: 'published' }] }] + : []; + listed = true; + return ok({ value }); + } + return ok(); + }); + graph.postZip.mockImplementation(async (_token: string, path: string) => + path.includes('/appDefinitions') ? ok({ id: TEAMS_APP_ID }) : failed(409, 'Conflict'), + ); + + await expect(service.publish(publishOptions)).resolves.toEqual({ + status: 'published', + teamsAppId: TEAMS_APP_ID, + }); + expect(zipCallTo('/appDefinitions')).toBeDefined(); + }); + + /** + * The clash Microsoft reports names our own manifest id, so the app is + * this agent's however little the listing knows about it. Telling the + * user to rename the agent would have them rebuild everything over a + * delay that clears itself. + */ + it('does not blame the agent for a clash with its own app', async () => { + graph.postZip.mockResolvedValue( + failed( + 409, + 'Conflict', + `App with same id already exists in the tenant. ExternalId: '${EXTERNAL_ID}', state: Installed`, + ), + ); + // Still not listed, which is why the second publish happened at all. + withCatalog(ok({ value: [] })); + + vi.useFakeTimers(); + // The handler is attached before the clock moves: a promise left + // unwatched across the retries rejects with nobody listening, and + // vitest fails the whole run on that. + const pending = service.publish(publishOptions).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(30_000); + + const error = await pending; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/try again in a few minutes/i); + expect((error as Error).message).not.toMatch(/Rename the agent/); + }); + + /** + * Microsoft words these refusals as it likes, so a message that does not + * name our app proves nothing. The copy has to fit both readings rather + * than send someone to rename an agent whose own earlier publish took the + * name. + */ + it('leaves a taken name it cannot attribute open to either cause', async () => { + graph.postZip.mockResolvedValue(failed(409, 'Conflict')); + + const error = await service.publish(publishOptions).catch((caught: Error) => caught); + + expect((error as Error).message).toMatch(/try again in a few minutes/i); + expect((error as Error).message).toMatch(/rename the agent/i); + }); + + /** + * The catalogue listing is filtered on a value Microsoft indexes after the + * write, so a read taken straight after a create can come back empty. + * Reporting "not listed yet" for an app just created is what sends someone + * round to publish a second one. + */ + it('reports the app it just created, not the listing that lags behind', async () => { + graph.postZip.mockResolvedValue(ok({ id: TEAMS_APP_ID })); + withCatalog(ok({ value: [] })); + + await expect(service.publish(publishOptions)).resolves.toEqual({ + status: 'published', + teamsAppId: TEAMS_APP_ID, + }); + }); + + it('tells the user a refusal may pass, because it is often Microsoft-side', async () => { + graph.postZip.mockResolvedValue(failed(500)); + + await expect(service.publish(publishOptions)).rejects.toThrow(/Try again in a few minutes/); + }); + + it('updates the catalogued app instead of adding a second one', async () => { + withCatalog(catalogListing('published')); + + await service.publish(publishOptions); + + expect(zipCallTo(`/appCatalogs/teamsApps/${TEAMS_APP_ID}/appDefinitions`)).toBeDefined(); + // The create takes the collection path with no query, so an exact match + // is the only one that would catch a second app being added. + expect(zipCallToExactly('/appCatalogs/teamsApps')).toBeUndefined(); + }); + + /** + * Without this a member could publish an app once and never update it: + * only an administrator may change a catalogue entry outright, and the + * review route is the same one the first publish falls back to. + */ + it('sends an update for review when the account may not change the entry', async () => { + withCatalog(catalogListing('published')); + const path = `/appCatalogs/teamsApps/${TEAMS_APP_ID}/appDefinitions`; + graph.postZip.mockImplementation(async (_t: string, called: string) => + called.includes('requiresReview=true') ? ok({ id: TEAMS_APP_ID }) : failed(403), + ); + + await service.publish(publishOptions); + + expect(zipCallTo(`${path}?requiresReview=true`)).toBeDefined(); + }); + + it('says who has to publish when even a review is refused', async () => { + withCatalog(catalogListing('published')); + graph.postZip.mockResolvedValue(failed(403)); + + await expect(service.publish(publishOptions)).rejects.toThrow( + /Teams administrator has to publish/, + ); + }); + + it('builds the package from the settings in the open form', async () => { + const settings = { sessionIdleTimeoutMinutes: 30 }; + + await service.publish({ ...publishOptions, settings }); + + expect(setupService.buildPackage).toHaveBeenCalledWith( + user, + { projectId: 'project-1', agentId: 'agent-1' }, + 'bot-cred-1', + settings, + ); + }); + + it('refuses without a Microsoft sign-in', async () => { + credentialsFinderService.findCredentialForUser.mockResolvedValue(null as never); + + await expect(service.publish(publishOptions)).rejects.toThrow(BadRequestError); + }); + + /** + * Publishing puts an app in front of everyone in the organisation, as the + * account that signed in. Being allowed to see that sign-in is the lower + * rung, and does not carry standing to act with it. + */ + it('refuses a sign-in the user may see but not act with', async () => { + // Answers for reading the credential, refuses for acting with it. + credentialsFinderService.findCredentialForUser.mockImplementation((async ( + id: string, + _user: unknown, + scopes: string[], + ) => + scopes.includes('credential:use') + ? null + : mock({ + id, + type: + id === 'bot-cred-1' + ? 'microsoftEntraServicePrincipalApi' + : 'microsoftTeamsManagerOAuth2Api', + })) as never); + + await expect(service.publish(publishOptions)).rejects.toThrow(BadRequestError); + // The token is the first act with the sign-in, and redeeming it rotates + // the refresh token in the customer's directory. Nothing ran. + expect(tokens.acquire).not.toHaveBeenCalled(); + expect(graph.postZip).not.toHaveBeenCalled(); + }); + + /** + * The listing still shows the definition that was approved before, so + * asking it would report the change as live while it waits for somebody + * to approve it. + */ + it('reports an update sent for review as submitted, not published', async () => { + withCatalog(catalogListing('published')); + graph.postZip.mockImplementation(async (_t: string, path: string) => + path.includes('requiresReview=true') ? ok({ id: TEAMS_APP_ID }) : failed(403, 'Forbidden'), + ); + + await expect(service.publish(publishOptions)).resolves.toMatchObject({ + status: 'submitted', + }); + }); + }); + + describe('getState', () => { + it.each([ + ['published', 'published'], + ['submitted', 'submitted'], + ['rejected', 'rejected'], + ])('reports %s back from Microsoft', async (publishingState, expected) => { + withCatalog(catalogListing(publishingState)); + + await expect(service.getState(scope)).resolves.toEqual({ + status: expected, + teamsAppId: TEAMS_APP_ID, + }); + }); + + it('reports an app that is not listed yet as unknown, never rejected', async () => { + withCatalog(ok({ value: [] })); + + await expect(service.getState(scope)).resolves.toEqual({ + status: 'unknown', + teamsAppId: null, + }); + }); + + it('reports an unrecognised state as unknown', async () => { + withCatalog(catalogListing('somethingNew')); + + await expect(service.getState(scope)).resolves.toMatchObject({ status: 'unknown' }); + }); + + /** + * Microsoft has no route that reads one app back, and the listing does not + * return an app for minutes after a publish -- sometimes for a day. Asked + * in that window it answers the same way it answers about an app nobody + * published, so a setup reopened in it offered to publish a second time. + */ + it('falls back to the publish it recorded when the listing cannot answer', async () => { + withCatalog(ok({ value: [] })); + withBotCredential({ + publishedTeamsAppId: TEAMS_APP_ID, + publishedTeamsAppState: 'published', + publishedTeamsAppAt: new Date().toISOString(), + }); + + await expect(service.getState({ ...scope, credentialId: 'bot-cred-1' })).resolves.toEqual({ + status: 'published', + teamsAppId: TEAMS_APP_ID, + }); + }); + + /** + * The note only stands in for a listing that has not caught up. Past that + * window the catalogue is the better answer: an administrator may have + * removed the app, and a note that never expires reports it published + * for good, with no way to publish it again. + */ + it('stops answering for a publish older than the listing ever lags', async () => { + withCatalog(ok({ value: [] })); + withBotCredential({ + publishedTeamsAppId: TEAMS_APP_ID, + publishedTeamsAppState: 'published', + publishedTeamsAppAt: new Date(Date.now() - 49 * 60 * 60 * 1000).toISOString(), + }); + + await expect(service.getState({ ...scope, credentialId: 'bot-cred-1' })).resolves.toEqual({ + status: 'unknown', + teamsAppId: null, + }); + }); + + /** Written before the window existed, so the catalogue answers instead. */ + it('ignores a note with no timestamp', async () => { + withCatalog(ok({ value: [] })); + withBotCredential({ + publishedTeamsAppId: TEAMS_APP_ID, + publishedTeamsAppState: 'published', + }); + + await expect(service.getState({ ...scope, credentialId: 'bot-cred-1' })).resolves.toEqual({ + status: 'unknown', + teamsAppId: null, + }); + }); + + it('prefers what Microsoft says over what it recorded', async () => { + withCatalog(catalogListing('rejected')); + withBotCredential({ + publishedTeamsAppId: TEAMS_APP_ID, + publishedTeamsAppState: 'published', + publishedTeamsAppAt: new Date().toISOString(), + }); + + await expect(service.getState({ ...scope, credentialId: 'bot-cred-1' })).resolves.toEqual({ + status: 'rejected', + teamsAppId: TEAMS_APP_ID, + }); + }); + + it('finds the app by the id in our own manifest, storing nothing', async () => { + await service.getState(scope); + + const call = graph.request.mock.calls.find(([, , path]) => + String(path).startsWith('/appCatalogs/teamsApps?'), + ); + const filter = new URLSearchParams(String(call?.[2]).split('?')[1]).get('$filter'); + expect(filter).toBe(`externalId eq '${EXTERNAL_ID}'`); + }); + }); + + describe('recording the publish', () => { + it('writes the app it made onto the channel credential', async () => { + withBotCredential({ clientId: 'app-1' }); + graph.postZip.mockResolvedValue(ok({ id: TEAMS_APP_ID })); + + await service.publish({ ...publishOptions, settings: undefined }); + + expect(credentialsService.update).toHaveBeenCalledWith( + 'bot-cred-1', + expect.objectContaining({ data: 'encrypted' }), + { kind: 'user', user }, + expect.objectContaining({ + clientId: 'app-1', + publishedTeamsAppId: TEAMS_APP_ID, + publishedTeamsAppState: 'published', + publishedTeamsAppAt: expect.any(String), + }), + ); + }); + + /** Microsoft has the app either way, so losing the note must not undo it. */ + it('still reports the publish when the note cannot be written', async () => { + withBotCredential({ clientId: 'app-1' }); + credentialsService.update.mockRejectedValue(new Error('database is locked') as never); + graph.postZip.mockResolvedValue(ok({ id: TEAMS_APP_ID })); + + await expect(service.publish({ ...publishOptions, settings: undefined })).resolves.toEqual({ + status: 'published', + teamsAppId: TEAMS_APP_ID, + }); + }); + }); + + describe('an app still waiting for an administrator', () => { + it('reports submitted without failing, so the channel can still connect', async () => { + withCatalog(catalogListing('submitted')); + + // Nothing here throws or blocks: submitting is about org-wide + // distribution, and the bot already works for anyone who has the app. + await expect(service.getState(scope)).resolves.toEqual({ + status: 'submitted', + teamsAppId: TEAMS_APP_ID, + }); + }); + + it('keeps reporting submitted on a re-check, rather than turning into a failure', async () => { + withCatalog(catalogListing('submitted')); + + await service.getState(scope); + await expect(service.getState(scope)).resolves.toMatchObject({ status: 'submitted' }); + }); + }); +}); diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-entra-provisioning.service.test.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-entra-provisioning.service.test.ts index 624d0f6b1aa..fdfd30d5697 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-entra-provisioning.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-entra-provisioning.service.test.ts @@ -1,4 +1,5 @@ import type { CredentialsEntity, User } from '@n8n/db'; +import { CREDENTIAL_BLANKING_VALUE } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; import type { CredentialsFinderService } from '@n8n/backend-services'; @@ -382,6 +383,76 @@ describe('TeamsEntraProvisioningService', () => { ); }); + /** + * The publish records which app it put in the catalogue on this very + * credential, and that note is the only answer the setup has while + * Microsoft has not listed the app. A re-run must not lose it. + */ + it('keeps what the publish recorded on the credential', async () => { + credentialsService.decrypt.mockImplementation( + async (credential: { id: string }, includeRawData?: boolean) => { + if (credential.id !== 'bot-cred-1') return {} as never; + const data = { + entraAppObjectId: OBJECT_ID, + provisionedForAgentId: 'agent-1', + publishedTeamsAppId: 'teams-app-1', + publishedTeamsAppState: 'published', + privateKey: 'pem-1', + }; + // The real `decrypt` blanks every password field unless the caller + // asks for the raw data, and this one is written straight back. + return ( + includeRawData ? data : { ...data, privateKey: CREDENTIAL_BLANKING_VALUE } + ) as never; + }, + ); + + await service.provision(options); + + expect(credentialsService.createEncryptedData).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + publishedTeamsAppId: 'teams-app-1', + publishedTeamsAppState: 'published', + privateKey: 'pem-1', + }), + }), + ); + }); + + /** + * A re-run against another directory keeps the credential but not what it + * said about a catalogue the new tenant has never seen -- carrying that + * across suppresses the publish that is actually due. + */ + it('drops the publish note when the app lands in another tenant', async () => { + credentialsService.decrypt.mockImplementation( + async (credential: { id: string }) => + (credential.id === 'bot-cred-1' + ? { + entraAppObjectId: OBJECT_ID, + provisionedForAgentId: 'agent-1', + tenantId: 'a-different-tenant', + clientId: APP_ID, + publishedTeamsAppId: 'teams-app-1', + publishedTeamsAppState: 'published', + publishedTeamsAppAt: '2026-10-01T00:00:00Z', + } + : {}) as never, + ); + + await service.provision(options); + + const written = credentialsService.createEncryptedData.mock.calls[0][0].data as Record< + string, + unknown + >; + expect(written.publishedTeamsAppId).toBeUndefined(); + expect(written.publishedTeamsAppState).toBeUndefined(); + expect(written.publishedTeamsAppAt).toBeUndefined(); + expect(written.tenantId).toBe(TENANT_ID); + }); + it('reuses the app it registered, rather than registering a second one', async () => { const result = await service.provision(options); diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-managed-setup.service.test.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-managed-setup.service.test.ts index 9730755037c..34c912acf0a 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-managed-setup.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-managed-setup.service.test.ts @@ -147,6 +147,24 @@ describe('TeamsManagedSetupService', () => { }); }); + /** + * A sign-in made before publishing existed carries no catalogue consent, + * and both scopes are admin-consent-only -- so it has to be redone rather + * than fail on the publish call. + */ + it('asks for a reconnect when the grant predates the catalogue scopes', async () => { + withManagerCredential({ + oauthTokenData: { + access_token: 'token', + refresh_token: 'refresh-1', + scope: 'openid https://graph.microsoft.com/Application.ReadWrite.All', + }, + }); + + const [summary] = (await service.getSetupState(scope)).managerCredentials; + expect(summary).toMatchObject({ connected: true, reconnectRequired: true }); + }); + it('asks for a reconnect when a required scope is missing from the grant', async () => { withManagerCredential({ oauthTokenData: { diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-setup.service.test.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-setup.service.test.ts index 9bc267262ca..32417d00a4c 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-setup.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-setup.service.test.ts @@ -152,6 +152,57 @@ describe('TeamsSetupService', () => { expect(JSON.stringify(state)).not.toContain('super-secret'); }); + + /** + * The channel settings offer publishing only for an app n8n made for this + * agent. Reading the provenance too loosely offers it for an app n8n + * cannot change, and reading it too strictly sends the user back to the + * manual package. + */ + describe('the provenance that offers the managed app', () => { + const provisionedFor = (agentId: string) => { + connectTeamsCredential(); + credentialsService.decrypt.mockResolvedValue({ + clientId: CLIENT_ID, + tenantId: TENANT_ID, + clientSecret: 'super-secret', + provisionedForAgentId: agentId, + }); + }; + + it('claims the credential the recommended setup made for this agent', async () => { + provisionedFor(AGENT_ID); + + const state = await service.getSetupState(user, { + projectId: PROJECT_ID, + agentId: AGENT_ID, + }); + + expect(state.provisionedByN8n).toBe(true); + }); + + it('disclaims a hand-made credential, which carries no provenance', async () => { + connectTeamsCredential(); + + const state = await service.getSetupState(user, { + projectId: PROJECT_ID, + agentId: AGENT_ID, + }); + + expect(state.provisionedByN8n).toBe(false); + }); + + it('disclaims one the recommended setup made for a different agent', async () => { + provisionedFor('agent-2'); + + const state = await service.getSetupState(user, { + projectId: PROJECT_ID, + agentId: AGENT_ID, + }); + + expect(state.provisionedByN8n).toBe(false); + }); + }); }); describe('a credential another agent already uses', () => { diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-catalog.service.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-catalog.service.ts new file mode 100644 index 00000000000..2f39530624d --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-catalog.service.ts @@ -0,0 +1,459 @@ +import type { AgentTeamsIntegrationSettings, TeamsCatalogState } from '@n8n/api-types'; +import type { CredentialsEntity, User } from '@n8n/db'; +import { Logger } from '@n8n/backend-common'; +import { Service } from '@n8n/di'; +import { isRecord } from '@n8n/utils/is-record'; +import { sleep } from '@n8n/utils/sleep'; +import { OperationalError, UserError } from 'n8n-workflow'; + +import { CredentialsFinderService } from '@n8n/backend-services'; +import { BadRequestError } from '@n8n/errors'; + +import { CredentialsService } from '@/credentials/credentials.service'; + +import { BOT_CREDENTIAL_TYPE } from './teams-entra-provisioning.service'; +import { graphErrorCode, graphErrorMessage, TeamsGraphService } from './teams-graph.service'; +import { TeamsManifestService } from './teams-manifest.service'; +import { TEAMS_MANAGER_CREDENTIAL_TYPE } from './teams-managed-setup.service'; +import { GRAPH_RESOURCE, TeamsManagerTokenService } from './teams-manager-token.service'; +import { TeamsSetupService } from './teams-setup.service'; +import { stringProperty } from '../../integration-helpers'; + +/** + * How long the recorded publish answers for the catalogue. Microsoft takes up + * to a day or two to list a published app, and the note exists only to cover + * that. The far end of the range: expiring early offers a publish that already + * happened, which Microsoft then refuses as a duplicate. + */ +const PUBLISH_NOTE_TTL_MS = 48 * 60 * 60 * 1000; + +export interface TeamsCatalogScope { + user: User; + projectId: string; + agentId: string; + managerCredentialId: string; + /** The channel's own credential, which carries what the last publish made. */ + credentialId?: string; + /** Skips the listing, which answers empty straight after a publish. */ + teamsAppId?: string; +} + +/** + * Covers the seconds the listing index usually lags a write by, and no more. + * Microsoft documents up to a day for an availability change to apply, which + * no retry can sit through -- past this the step says to come back later. + */ +const CATALOG_LOOKUP_ATTEMPTS = 3; +const CATALOG_LOOKUP_DELAY_MS = 2000; + +interface CatalogEntry { + teamsAppId: string; + publishingState: string | null; +} + +export interface PublishTeamsAppOptions extends TeamsCatalogScope { + credentialId: string; + settings?: AgentTeamsIntegrationSettings; +} + +/** + * Publishes the generated app package to the organisation's Teams catalog. + * + * Publishing directly needs the Teams administrator role. Everyone else may + * only submit for review, and Microsoft decides which of the two applies — so + * the capability is read from what the publish call answers rather than from + * asking the user what role they hold. + */ +@Service() +export class TeamsCatalogService { + constructor( + private readonly graph: TeamsGraphService, + private readonly tokens: TeamsManagerTokenService, + private readonly setupService: TeamsSetupService, + private readonly manifestService: TeamsManifestService, + private readonly credentialsFinderService: CredentialsFinderService, + private readonly credentialsService: CredentialsService, + private readonly logger: Logger, + ) {} + + async publish(options: PublishTeamsAppOptions): Promise { + const state = await this.publishToCatalog(options); + await this.rememberPublish(options, state); + return state; + } + + private async publishToCatalog(options: PublishTeamsAppOptions): Promise { + const token = await this.graphToken(options); + const archive = await this.setupService.buildPackage( + options.user, + { projectId: options.projectId, agentId: options.agentId }, + options.credentialId, + options.settings, + ); + + const existing = await this.findCatalogApp(token, options.agentId); + if (existing) { + const status = await this.updateExistingApp(token, existing.teamsAppId, archive); + return { status, teamsAppId: existing.teamsAppId }; + } + + // A Teams administrator may publish outright. Anyone else is refused, and + // the same package goes in for review instead. That refusal is the only + // reliable way to tell the two apart. + const direct = await this.graph.postZip(token, '/appCatalogs/teamsApps', archive); + if (direct.ok) { + this.logger.debug('[TeamsCatalog] Published straight to the organisation', { + agentId: options.agentId, + teamsAppId: stringProperty(direct.body, 'id') ?? null, + externalId: this.manifestService.buildManifestId(options.agentId), + }); + return this.createdState(direct.body, 'published'); + } + + if (direct.statusCode === 409 || graphErrorCode(direct.body) === 'Conflict') { + return await this.publishOverConflict(token, options.agentId, archive, direct.body); + } + + if (!this.isPermissionRefusal(direct.statusCode, graphErrorCode(direct.body))) { + throw this.publishError(direct.statusCode, direct.body); + } + + const submitted = await this.graph.postZip( + token, + '/appCatalogs/teamsApps?requiresReview=true', + archive, + ); + if (submitted.ok) { + // A submitted app is not in the catalogue until somebody approves it, + // which is why nothing can be installed from it yet. + this.logger.debug('[TeamsCatalog] Sent for review instead of published', { + agentId: options.agentId, + teamsAppId: stringProperty(submitted.body, 'id') ?? null, + refusedWith: direct.statusCode, + }); + return this.createdState(submitted.body, 'submitted'); + } + + if (submitted.statusCode === 409 || graphErrorCode(submitted.body) === 'Conflict') { + return await this.publishOverConflict(token, options.agentId, archive, submitted.body); + } + throw this.publishError(submitted.statusCode, submitted.body); + } + + /** + * The listing is filtered on a value Microsoft indexes after the write, so a + * read taken straight after a create can come back empty. The created app + * answers for itself instead -- reporting "not listed yet" for an app we + * just made is what sends someone round to publish a second one. + */ + private createdState(body: unknown, status: 'published' | 'submitted'): TeamsCatalogState { + const teamsAppId = stringProperty(body, 'id'); + return teamsAppId ? { status, teamsAppId } : { status: 'unknown', teamsAppId: null }; + } + + /** + * The name is taken, which means a publish already landed -- here or on an + * earlier run whose result never came back. Updating that app is what the + * caller asked for, so the second attempt finishes the job instead of + * reporting a clash they cannot act on. + */ + private async publishOverConflict( + token: string, + agentId: string, + archive: Buffer, + refusal: unknown, + ): Promise { + // Microsoft names the app it clashed with, and names it by the id from + // our own manifest when the app is one of ours. That is a surer answer + // than the listing, which has not caught up -- it is the reason the + // caller published a second time. A message that does not name ours + // proves nothing, though: the wording is Microsoft's to change. + const externalId = this.manifestService.buildManifestId(agentId); + const ours = (graphErrorMessage(refusal) ?? '').includes(externalId); + + const existing = await this.awaitCatalogApp(token, agentId); + if (!existing) { + throw ours + ? new UserError( + 'This agent already has an app in the organisation catalogue, and Microsoft has not listed it yet, so it cannot be updated. Nothing is wrong and nothing was lost — try again in a few minutes.', + ) + : new UserError( + 'An app with this name is already in the organisation catalogue, and Microsoft has not listed it yet, so it cannot be updated. If an earlier publish made it, try again in a few minutes. If another app holds the name, rename the agent and try again.', + ); + } + const status = await this.updateExistingApp(token, existing.teamsAppId, archive); + return { status, teamsAppId: existing.teamsAppId }; + } + + /** + * Reads the review back from Microsoft rather than tracking it here. An + * n8n-side copy would be a second queue that can disagree with the real one. + */ + async getState(options: TeamsCatalogScope): Promise { + const token = await this.graphToken(options); + const state = await this.readState(token, options.agentId); + if (state.status !== 'unknown') return state; + + // The listing does not return an app for some minutes after a publish, + // and Microsoft allows itself a day. Asked in that window, it answers the + // same way it answers about an app that was never published -- so a setup + // reopened in it offered to publish again, and Microsoft refused the + // second publish as a duplicate. + return (await this.rememberedPublish(options)) ?? state; + } + + /** + * Records what the publish made, beside the app registration the channel + * credential already carries. It is the only account of the publish that + * outlives the dialog: Microsoft has no route that reads one app back. + */ + private async rememberPublish( + options: PublishTeamsAppOptions, + state: TeamsCatalogState, + ): Promise { + if (!state.teamsAppId || state.status === 'unknown') return; + try { + const credential = await this.botCredential(options, 'credential:update'); + if (!credential) return; + + const current = (await this.credentialsService.decrypt(credential, true)) as Record< + string, + unknown + >; + // Rewritten even when it says the same thing: the timestamp is what + // bounds it, so a re-publish has to push the window out. + + const updated = { + ...current, + publishedTeamsAppId: state.teamsAppId, + publishedTeamsAppState: state.status, + publishedTeamsAppAt: new Date().toISOString(), + }; + const encrypted = await this.credentialsService.createEncryptedData({ + id: credential.id, + name: credential.name, + type: credential.type, + data: updated, + }); + await this.credentialsService.update( + credential.id, + encrypted, + { kind: 'user', user: options.user }, + updated, + ); + } catch (error) { + // Microsoft has the app either way. Losing the note costs the user a + // stale step, which is not worth undoing a publish that worked. + this.logger.warn('[TeamsCatalog] Could not record the publish', { + agentId: options.agentId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + /** What the last publish made, for when the catalogue will not say. */ + private async rememberedPublish( + options: TeamsCatalogScope, + ): Promise { + const credential = await this.botCredential(options, 'credential:read'); + if (!credential) return undefined; + + const data = await this.credentialsService.decrypt(credential, true); + const teamsAppId = stringProperty(data, 'publishedTeamsAppId'); + const status = stringProperty(data, 'publishedTeamsAppState'); + if (!teamsAppId || (status !== 'published' && status !== 'submitted')) return undefined; + + // The note stands in for a listing that has not caught up, so it is only + // worth what that lag costs. Past the window the catalogue is the better + // answer: an administrator may have removed the app, and a note that + // never expires would report it published for good. A note written + // before the window existed carries no timestamp, and reading it as + // expired puts those credentials back on the catalogue's own answer. + const writtenAt = Date.parse(stringProperty(data, 'publishedTeamsAppAt') ?? ''); + if (Number.isNaN(writtenAt) || Date.now() - writtenAt > PUBLISH_NOTE_TTL_MS) { + return undefined; + } + return { status, teamsAppId }; + } + + private async botCredential( + options: TeamsCatalogScope, + scope: 'credential:read' | 'credential:update', + ): Promise { + if (!options.credentialId) return undefined; + const credential = await this.credentialsFinderService.findCredentialForUser( + options.credentialId, + options.user, + [scope], + ); + return credential?.type === BOT_CREDENTIAL_TYPE ? credential : undefined; + } + + /** + * The same two-step the first publish takes: an administrator updates the + * catalogue entry outright, and anyone else has to ask for a review. Without + * the retry a member could publish an app once and never update it again. + * + * It answers which route the update took, because the listing cannot: an + * update sent for review leaves the previously approved definition in place, + * so it still reads `published` while the change waits for somebody. + */ + private async updateExistingApp( + token: string, + teamsAppId: string, + archive: Buffer, + ): Promise<'published' | 'submitted'> { + const path = `/appCatalogs/teamsApps/${teamsAppId}/appDefinitions`; + const direct = await this.graph.postZip(token, path, archive); + if (direct.ok) return 'published'; + + if (!this.isPermissionRefusal(direct.statusCode, graphErrorCode(direct.body))) { + throw this.publishError(direct.statusCode, direct.body); + } + + const submitted = await this.graph.postZip(token, `${path}?requiresReview=true`, archive); + if (!submitted.ok) { + throw this.publishError(submitted.statusCode, submitted.body); + } + return 'submitted'; + } + + /** + * Graph serves catalogue entries only as a filtered collection -- there is no + * read of a single app by key -- and the index behind that filter is written + * after the app is. So the entry a publish just made is missing for a short + * while, and the only honest answer is to ask again. + */ + private async awaitCatalogApp(token: string, agentId: string): Promise { + for (let attempt = 0; attempt < CATALOG_LOOKUP_ATTEMPTS; attempt++) { + if (attempt > 0) await sleep(CATALOG_LOOKUP_DELAY_MS); + + const byExternalId = await this.queryCatalog( + token, + `externalId eq '${this.manifestService.buildManifestId(agentId)}'`, + ); + if (byExternalId) return byExternalId; + + this.logger.debug('[TeamsCatalog] Catalogue has not listed the app yet', { + agentId, + attempt: attempt + 1, + of: CATALOG_LOOKUP_ATTEMPTS, + }); + } + return undefined; + } + + private async queryCatalog(token: string, filter: string): Promise { + const query = new URLSearchParams({ $filter: filter, $expand: 'appDefinitions' }); + const response = await this.graph.request( + token, + 'GET', + `/appCatalogs/teamsApps?${query.toString()}`, + ); + // A refusal and an empty catalogue both answer `undefined`, and the caller + // tells the user the app is not listed yet. Logged so a missing + // `AppCatalog` consent does not read as Microsoft still catching up. + if (!response.ok) { + this.logger.debug('[TeamsCatalog] Could not read the catalogue', { + statusCode: response.statusCode, + error: graphErrorCode(response.body), + }); + return undefined; + } + const value = isRecord(response.body) ? response.body.value : undefined; + return this.toCatalogEntry(Array.isArray(value) ? value[0] : undefined); + } + + /** + * The app is found by the id in our own manifest, which is derived from the + * agent id, so nothing has to be stored to find it again. + */ + private async findCatalogApp(token: string, agentId: string): Promise { + return await this.queryCatalog( + token, + `externalId eq '${this.manifestService.buildManifestId(agentId)}'`, + ); + } + + private toCatalogEntry(entry: unknown): CatalogEntry | undefined { + const teamsAppId = stringProperty(entry, 'id'); + if (!teamsAppId) return undefined; + + const definitions = isRecord(entry) ? entry.appDefinitions : undefined; + const latest = Array.isArray(definitions) ? definitions[definitions.length - 1] : undefined; + return { + teamsAppId, + publishingState: stringProperty(latest, 'publishingState') ?? null, + }; + } + + private async readState(token: string, agentId: string): Promise { + const app = await this.findCatalogApp(token, agentId); + if (!app) { + // Policies take a day or two to apply, so an app that is not listed yet + // is unknown rather than rejected. Treating absent as terminal would + // tell a user their app was refused when it is merely not visible. + return { status: 'unknown', teamsAppId: null }; + } + + switch (app.publishingState) { + case 'published': + return { status: 'published', teamsAppId: app.teamsAppId }; + case 'submitted': + return { status: 'submitted', teamsAppId: app.teamsAppId }; + case 'rejected': + return { status: 'rejected', teamsAppId: app.teamsAppId }; + default: + return { status: 'unknown', teamsAppId: app.teamsAppId }; + } + } + + /** A user without the Teams administrator role is refused this way. */ + private isPermissionRefusal(statusCode: number, code: string | undefined): boolean { + return statusCode === 403 || code === 'Forbidden' || code === 'UnauthorizedAccessException'; + } + + /** + * Deliberately vague to the user. Microsoft's own wording for a rejected + * package runs to internal request ids and backend host names — unreadable, + * and nothing a user can act on. The full text goes to the log instead, where + * whoever can act on it will look. + */ + private publishError(statusCode: number, body: unknown): Error { + if (statusCode === 409 || graphErrorCode(body) === 'Conflict') { + return new UserError('An app with this name is already in the organisation catalogue.'); + } + // Waiting changes nothing about a role the account does not hold, and this + // is the answer when even the review route is closed to it. + if (this.isPermissionRefusal(statusCode, graphErrorCode(body))) { + return new UserError( + 'This account may not put the app in the organisation catalogue. A Teams administrator has to publish it.', + ); + } + return new OperationalError( + 'Microsoft would not accept the Teams app. Try again in a few minutes.', + ); + } + + private async graphToken(options: TeamsCatalogScope): Promise { + const credential = await this.managerCredential(options); + return await this.tokens.acquire(credential, GRAPH_RESOURCE); + } + + /** + * `credential:use`, not `credential:read`: the token taken here puts an app + * in the organisation's catalogue, as this sign-in. Seeing a sign-in is not + * standing to act with it. + */ + private async managerCredential(options: TeamsCatalogScope): Promise { + const credential = await this.credentialsFinderService.findCredentialForUser( + options.managerCredentialId, + options.user, + ['credential:use'], + ); + if (!credential || credential.type !== TEAMS_MANAGER_CREDENTIAL_TYPE) { + throw new BadRequestError('Sign in with Microsoft before publishing the Teams app.'); + } + return credential; + } +} diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-entra-provisioning.service.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-entra-provisioning.service.ts index f61328c6cde..18dbe7a02c9 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-entra-provisioning.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-entra-provisioning.service.ts @@ -425,7 +425,35 @@ export class TeamsEntraProvisioningService { tenantId: string; existingCredential?: CredentialsEntity; }): Promise { + // Anything already on the credential is kept. The publish records what it + // put in the catalogue here, and that note is the only answer `getState` + // has during the day Microsoft can take to list the app -- rebuilding the + // data from scratch on a re-run would throw it away. + // Raw: the default redacts every password field, and this data is written + // straight back -- so a redacted read would store the blanking sentinel + // over whatever it did not overwrite. + const existingData = input.existingCredential + ? ((await this.credentialsService.decrypt(input.existingCredential, true)) as Record< + string, + unknown + >) + : {}; + + // The publish note answers for one app in one directory. A re-run that + // lands on either a different tenant or a different registration would + // otherwise carry it across, and `getState` would report a listing the + // new tenant has never seen -- suppressing the publish that is due. + const movedIdentity = + (existingData.tenantId !== undefined && existingData.tenantId !== input.tenantId) || + (existingData.clientId !== undefined && existingData.clientId !== input.app.appId); + if (movedIdentity) { + delete existingData.publishedTeamsAppId; + delete existingData.publishedTeamsAppState; + delete existingData.publishedTeamsAppAt; + } + const data = { + ...existingData, tenantId: input.tenantId, clientId: input.app.appId, clientSecret: input.secret.value, diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-graph.service.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-graph.service.ts index e687802a35d..87a9d83bec2 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-graph.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-graph.service.ts @@ -86,6 +86,40 @@ export class TeamsGraphService { } return { statusCode: response.statusCode, body: response.body, ok }; } + + /** + * The app catalog takes the package as a raw zip rather than JSON, so the + * body is sent unencoded with the zip content type. + */ + async postZip(accessToken: string, path: string, archive: Buffer): Promise { + const response = await this.outboundHttp + // Fixed public vendor host, not user-controllable. + .requests({ useDefaultSsrfPolicy: 'unsafe' }) + .request({ + method: 'POST', + url: graphUrl(path), + headers: { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/zip', + }, + body: archive, + returnFullResponse: true, + ignoreHttpStatusErrors: true, + timeout: GRAPH_TIMEOUT_MS, + }); + + const ok = response.statusCode >= 200 && response.statusCode < 300; + if (!ok) { + // The outer code is generic for a rejected package; the reason is in the + // inner code and the message, which name the manifest field at fault. + this.logger.warn('[TeamsGraph] Graph refused the app package', { + path, + statusCode: response.statusCode, + error: describeGraphError(response.body) ?? graphErrorCode(response.body), + }); + } + return { statusCode: response.statusCode, body: response.body, ok }; + } } /** Graph reports failures as `{ error: { code, message } }`. */ diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-managed-setup.service.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-managed-setup.service.ts index f91c6f3f97f..165284596bc 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-managed-setup.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-managed-setup.service.ts @@ -37,6 +37,8 @@ const DEFAULT_MANAGER_CREDENTIAL_NAME = 'Microsoft organization'; */ export const REQUIRED_MANAGER_SCOPES = [ 'https://graph.microsoft.com/Application.ReadWrite.All', + 'https://graph.microsoft.com/AppCatalog.ReadWrite.All', + 'https://graph.microsoft.com/AppCatalog.Submit', ] as const; export interface TeamsManagedSetupScope { diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-manager-token.service.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-manager-token.service.ts index 5843676f183..e1f2a765e4d 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-manager-token.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-manager-token.service.ts @@ -34,6 +34,8 @@ export const RESOURCE_SCOPES: Record = { [GRAPH_RESOURCE]: [ 'offline_access', `${GRAPH_RESOURCE}/Application.ReadWrite.All`, + `${GRAPH_RESOURCE}/AppCatalog.ReadWrite.All`, + `${GRAPH_RESOURCE}/AppCatalog.Submit`, `${GRAPH_RESOURCE}/User.Read`, ].join(' '), [AZURE_RESOURCE]: ['offline_access', `${AZURE_RESOURCE}/user_impersonation`].join(' '), diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-setup-telemetry.service.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-setup-telemetry.service.ts index 381095a8fb1..75f526c5865 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-setup-telemetry.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-setup-telemetry.service.ts @@ -4,7 +4,7 @@ import { UserError } from 'n8n-workflow'; import { Telemetry } from '@/telemetry'; -type Step = 'create_app' | 'create_bot'; +type Step = 'create_app' | 'create_bot' | 'install'; export interface TeamsSetupStepReport { agentId: string; @@ -13,6 +13,11 @@ export interface TeamsSetupStepReport { step: Step; /** `manual` is the rung taken when the account reaches no subscription. */ botRoute?: 'provisioned' | 'manual'; + /** + * Only the catalogue routes. A sideload finishes in the Teams client, where + * n8n cannot see it, so it reports no outcome at all. + */ + installRoute?: 'published' | 'submitted'; } /** @@ -79,6 +84,7 @@ export class TeamsSetupTelemetryService { step: report.step, outcome, ...(report.botRoute ? { bot_route: report.botRoute } : {}), + ...(report.installRoute ? { install_route: report.installRoute } : {}), ...(reason ? { reason } : {}), }); } catch { diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-setup.service.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-setup.service.ts index dbbac7b2b16..8f0aa8d51d9 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-setup.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-setup.service.ts @@ -24,6 +24,8 @@ interface AgentScope { interface BotIdentity { clientId: string; tenantId: string; + /** Written by the recommended setup, so a hand-made credential lacks it. */ + provisionedForAgentId?: string; } @Service() @@ -64,6 +66,7 @@ export class TeamsSetupService { ? this.armTemplateService.buildDeployUrl(scope.projectId, scope.agentId, credentialId) : null, credentialClaimedBy: claimedBy, + provisionedByN8n: identity?.provisionedForAgentId === agent.id, ...this.defaultIdentity(agent.name), }; } @@ -265,7 +268,8 @@ export class TeamsSetupService { ); const clientId = stringProperty(data, 'clientId'); const tenantId = stringProperty(data, 'tenantId'); - return clientId && tenantId ? { clientId, tenantId } : null; + const provisionedForAgentId = stringProperty(data, 'provisionedForAgentId'); + return clientId && tenantId ? { clientId, tenantId, provisionedForAgentId } : null; } private messagingEndpointUrl(scope: AgentScope): string { diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 3c50838821e..5d70bd9cbdd 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -9599,6 +9599,8 @@ "agents.channels.teams.setup.createBot.existingBot": "Use an existing Azure Bot", "agents.channels.teams.setup.createBot.existingBotHint": "Paste this into the bot’s Configuration page and turn on the Microsoft Teams channel.", "agents.channels.teams.setup.availability.whereTitle": "Where can people use the agent?", + "agents.channels.teams.settings.updateNoticeManaged": "Teams has the previous version of the app. Select Publish to organisation to send this change, or download the new package.", + "agents.channels.teams.settings.updateNoticeManagedUnchanged": "A change here does not reach Teams on its own. Use the buttons below to send it.", "agents.channels.teams.settings.updateNotice": "You’ll need to upload the app again to apply changes.", "agents.channels.teams.settings.uploadHint": "To update, upload the new package in Teams under Apps › Manage your apps › Upload an app.", "agents.channels.teams.settings.findBot": "Find your Azure Bot", @@ -9634,7 +9636,11 @@ "agents.channels.teams.managed.install.locked": "Choose where it is available to continue.", "agents.channels.teams.managed.finish.title": "Connect Microsoft Teams", "agents.channels.teams.managed.finish.description": "Finish adding the app in Teams, then press Done.", - "agents.channels.teams.managed.finish.locked": "Download the app package first.", + "agents.channels.teams.managed.finish.locked": "Install the app first. What to do next depends on how you install it.", + "agents.channels.teams.managed.finish.published": "Added to your organisation's app catalog. A Teams administrator makes it available to everyone in the Teams admin centre, under Teams apps › Manage apps.", + "agents.channels.teams.managed.finish.soonerBefore": "Want it sooner for yourself?", + "agents.channels.teams.managed.finish.soonerLink": "Download the package", + "agents.channels.teams.managed.finish.soonerAfter": "and upload it in Teams.", "agents.channels.teams.managed.useOwnApp": "Use your own Microsoft app instead", "agents.channels.teams.managed.createApp.button": "Create Teams app", "agents.channels.teams.managed.createApp.summary": "Registered in {organization}.", @@ -9650,11 +9656,31 @@ "agents.channels.teams.managed.createApp.progress": "Creating Teams app, this can take up to 30 seconds…", "agents.channels.teams.managed.createBot.noSubscriptionsFound": "No subscriptions found", "agents.channels.teams.managed.install.identityTooltip": "Name and avatar come from the agent. Teams needs a description, so this default is used. To change any of it, edit the agent and update the app in Teams.", + "agents.channels.teams.managed.install.publishing": "Publishing to your organisation\u2026", "agents.channels.teams.managed.install.preparing": "Preparing the app package\u2026", + "agents.channels.teams.managed.app.notPublished": "Only people you give the app package to can use this agent. Publish it to let anyone in your organisation add it.", + "agents.channels.teams.managed.app.submitted": "Waiting for your Teams administrator to approve it. Nobody can add it until they do.", + "agents.channels.teams.managed.app.rejected": "Your Teams administrator rejected the app. Microsoft gives no reason here, so ask them in the Teams admin centre.", + "agents.channels.teams.managed.app.checking": "Checking where the app stands in your organisation\u2026", + "agents.channels.teams.managed.app.unknown": "Could not read where the app stands in your organisation. Publishing still works.", + "agents.channels.teams.managed.app.published": "Published to your organisation. It can take up to a day before Microsoft lets it be added.", + "agents.channels.teams.managed.install.menu": "Install app", "agents.channels.teams.managed.install.forMe": "Install for me", + "agents.channels.teams.managed.install.forMeHint": "Downloads the package to upload in Teams. Only you get it.", + "agents.channels.teams.managed.install.publishHintShort": "Goes live for your organisation, or goes to your Teams administrator for approval first.", "agents.channels.teams.managed.install.downloaded": "Package downloaded. Upload the zip in Teams under Apps \u203a Manage your apps \u203a Upload an app.", "agents.channels.teams.managed.install.downloadAgain": "Download again", - "agents.channels.teams.managed.install.noUploadOption": "No Upload an app option? Ask your Teams administrator to allow custom app uploads.", + "agents.channels.teams.managed.install.noUploadOption": "No Upload an app option? Ask your Teams administrator to allow custom app uploads, or publish instead.", + "agents.channels.teams.managed.install.publish": "Publish to organisation", + "agents.channels.teams.managed.install.submitted": "Sent to your Teams administrator for approval. Nobody can add the app until they approve it.", + "agents.channels.teams.managed.install.submittedWhy": "n8n could not publish it outright. A Microsoft administrator approves n8n for the organisation once, and later publishes go live immediately.", + "agents.channels.teams.managed.install.copyApprovalRequest": "Copy a request for your administrator", + "agents.channels.teams.managed.install.approvalRequestCopied": "Copied — paste it to your administrator", + "agents.channels.teams.managed.install.approvalRequest": "Please approve \"{app}\" for Microsoft Teams: {url} — it is waiting under Pending approval.", + "agents.channels.teams.managed.install.published": "Published to your organisation.", + "agents.channels.teams.managed.install.rejected": "Your Teams administrator rejected the app. Microsoft gives no reason here, so ask them in the Teams admin centre.", + "agents.channels.teams.managed.install.unknown": "Microsoft has not listed the app yet. This can take a day or two, so check again later.", + "agents.channels.teams.managed.install.donePublished": "Published to your organisation. Microsoft can take up to a day to let people add it.", "agents.channels.teams.managed.error": "Something went wrong. {message}", "agents.channels.teams.managed.errors.signInFirst": "Sign in with Microsoft first.", "agents.channels.teams.managed.errors.createAppFirst": "Create the Teams app first.", diff --git a/packages/frontend/editor-ui/src/features/agents/channels/AgentChannelStandardEditView.vue b/packages/frontend/editor-ui/src/features/agents/channels/AgentChannelStandardEditView.vue index 74b1709f049..d71694c10e9 100644 --- a/packages/frontend/editor-ui/src/features/agents/channels/AgentChannelStandardEditView.vue +++ b/packages/frontend/editor-ui/src/features/agents/channels/AgentChannelStandardEditView.vue @@ -55,6 +55,8 @@ defineExpose({ currentSettings, validationError, saveLabel, beforeSave, afterSav :agent-name="agentName" :project-id="projectId" :agent-id="agentId" + :managed-runtime="runtime" + :persist-channel="persistChannel" /> diff --git a/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsEditView.test.ts b/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsEditView.test.ts index 98b1272d03a..581cad79504 100644 --- a/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsEditView.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsEditView.test.ts @@ -2,9 +2,11 @@ import { createComponentRenderer } from '@/__tests__/render'; import { describe, expect, it, vi, beforeEach } from 'vitest'; import { createTestingPinia } from '@pinia/testing'; import { configure, fireEvent, waitFor, within } from '@testing-library/vue'; +import { flushPromises } from '@vue/test-utils'; import { defineComponent, ref } from 'vue'; import AgentChannelTeamsEditView from './AgentChannelTeamsEditView.vue'; +import type { TeamsChannelRuntime } from './useTeamsChannelRuntime'; import { fetchTeamsAppPackage, getTeamsSetupState } from './api'; vi.mock('@n8n/i18n', async (importOriginal) => ({ @@ -32,6 +34,26 @@ configure({ testIdAttribute: 'data-testid' }); const WHERE_TITLE = 'agents.channels.teams.setup.availability.whereTitle'; +/** Only the parts the settings block reads; the rest never runs here. */ +const managedRuntime = (overrides: Record = {}) => + ({ + load: vi.fn(), + loading: { value: false }, + managedSetup: { + value: { + managedSetupAvailable: true, + managerCredentials: [{ id: 'm-1', name: 'Microsoft', connected: true }], + adminConsentUrl: null, + }, + }, + connectedCredentialId: ref(''), + catalogState: ref(null), + installed: ref(false), + publishApp: vi.fn(), + refreshCatalogState: vi.fn(), + ...overrides, + }) as unknown as TeamsChannelRuntime; + const viewProps = { mode: 'edit' as const, modelValue: 'cred-1', @@ -63,7 +85,7 @@ const viewProps = { managedSetup: { value: { managedSetupAvailable: false, managerCredentials: [], adminConsentUrl: null }, }, - } as never, + } as unknown as TeamsChannelRuntime, }; // The modal only ever talks to the outer view, so this stands in for it. @@ -103,6 +125,7 @@ describe('AgentChannelTeamsEditView', () => { botId: '11111111-2222-3333-4444-555555555555', deployToAzureUrl: null, credentialClaimedBy: null, + provisionedByN8n: true, defaultDisplayName: 'Support Bot', defaultDescription: 'Chat with Support Bot, an agent powered by n8n.', }); @@ -113,7 +136,7 @@ describe('AgentChannelTeamsEditView', () => { const { getByTestId } = renderHost(); await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled()); - expect(getByTestId('save-label')).toHaveTextContent(''); + expect(getByTestId('save-label')).toBeEmptyDOMElement(); await fireEvent.click( within(getByTestId('teams-availability')).getByLabelText(`Toggle ${WHERE_TITLE}`), @@ -136,12 +159,252 @@ describe('AgentChannelTeamsEditView', () => { ); }); - it('offers the package, which is the only route this view has', async () => { + /** + * Uploading the package needs no catalogue entry, so it is the only route + * that works through the day Microsoft can take to make a published app + * addable -- which is exactly when someone opens these settings. + */ + it('offers the package while Microsoft is not ready', async () => { const { getByTestId } = renderView({ + props: { + ...viewProps, + // The package has to carry what the channel runs on, not what the + // form is holding, or an upload ships a manifest n8n does not match. + savedSettings: { teamChannels: true }, + runtime: managedRuntime({ + catalogState: ref({ status: 'published', teamsAppId: 'teams-app-1' }), + }), + }, + pinia: createTestingPinia(), + }); + + await waitFor(() => expect(getByTestId('teams-settings-download')).toBeEnabled()); + await flushPromises(); + + await fireEvent.click(getByTestId('teams-settings-download')); + + await waitFor(() => + expect(fetchTeamsAppPackage).toHaveBeenCalledWith( + expect.anything(), + 'p', + 'a', + 'cred-1', + expect.objectContaining({ teamChannels: true }), + ), + ); + // The upload is a step in Teams, so the step is said rather than assumed. + expect(getByTestId('teams-managed-app-downloaded')).toBeVisible(); + }); + + /** + * Both of the managed route's answers are buttons on the card, and one of + * them reaches the whole organisation. The save takes neither: it stays a + * save, whatever the label would otherwise have promised. + */ + it('keeps the save a plain save on a managed channel', async () => { + const { getByTestId, queryByTestId } = renderHost({ + props: { props: { ...viewProps, runtime: managedRuntime() } }, + pinia: createTestingPinia(), + }); + + await waitFor(() => expect(getByTestId('teams-managed-app')).toBeVisible()); + // The read on open shows in the action, not as a spinner beside it. + expect(queryByTestId('teams-settings-refresh')).not.toBeInTheDocument(); + await fireEvent.click( + within(getByTestId('teams-availability')).getByLabelText(`Toggle ${WHERE_TITLE}`), + ); + await fireEvent.click(getByTestId('teams-scope-groups')); + + // Empty, so the modal falls back to its own Save. + expect(getByTestId('save-label')).toBeEmptyDOMElement(); + // The route is still offered, by the button that names what it does. + expect(getByTestId('teams-settings-publish')).toBeInTheDocument(); + }); + + /** + * The notice promises what the save does, and the two routes do opposite + * things with it: one hands over a file, the other reaches every person in + * the organisation. Naming the wrong one is the failure worth pinning. + */ + describe('the notice above the save', () => { + // Every key here carries `updateNotice` as a prefix, so a substring match + // would pass whichever one rendered. The whole text is the assertion. + const noticeOf = (getByTestId: (id: string) => HTMLElement) => + getByTestId('teams-update-notice').textContent?.trim(); + + const changeAvailability = async (getByTestId: (id: string) => HTMLElement) => { + await fireEvent.click( + within(getByTestId('teams-availability')).getByLabelText(`Toggle ${WHERE_TITLE}`), + ); + await fireEvent.click(getByTestId('teams-scope-groups')); + }; + + it('says a managed channel publishes, before anything is changed', async () => { + const { getByTestId } = renderView({ + props: { ...viewProps, runtime: managedRuntime() }, + pinia: createTestingPinia(), + }); + + await waitFor(() => + expect(noticeOf(getByTestId)).toBe( + 'agents.channels.teams.settings.updateNoticeManagedUnchanged', + ), + ); + }); + + it('says the save publishes the new version once something changed', async () => { + const { getByTestId } = renderView({ + props: { ...viewProps, runtime: managedRuntime() }, + pinia: createTestingPinia(), + }); + + await waitFor(() => expect(getByTestId('teams-availability')).toBeVisible()); + await changeAvailability(getByTestId); + + expect(noticeOf(getByTestId)).toBe('agents.channels.teams.settings.updateNoticeManaged'); + }); + + // The manual route says the same thing throughout: the package is handed + // over by the download, so a change here does not alter what to do next. + it('keeps one message for a hand-made channel, changed or not', async () => { + vi.mocked(getTeamsSetupState).mockResolvedValue({ + messagingEndpointUrl: 'https://n8n.test/webhook', + botId: 'bot-1', + deployToAzureUrl: null, + credentialClaimedBy: null, + provisionedByN8n: false, + defaultDisplayName: 'Support Bot', + defaultDescription: 'An agent built with n8n.', + }); + + const { getByTestId } = renderView({ + props: { ...viewProps, runtime: managedRuntime() }, + pinia: createTestingPinia(), + }); + + await waitFor(() => + expect(noticeOf(getByTestId)).toBe('agents.channels.teams.settings.updateNotice'), + ); + await changeAvailability(getByTestId); + + expect(noticeOf(getByTestId)).toBe('agents.channels.teams.settings.updateNotice'); + }); + }); + + /** + * The managed card acts on the channel's credential: it publishes it and + * hands out its package. A channel someone wired up by hand is not that + * one, even in a project that also carries a Microsoft sign-in. + */ + it('leaves a hand-made channel to the manual block', async () => { + vi.mocked(getTeamsSetupState).mockResolvedValue({ + messagingEndpointUrl: 'https://n8n.test/webhook', + botId: 'bot-1', + deployToAzureUrl: null, + credentialClaimedBy: null, + provisionedByN8n: false, + defaultDisplayName: 'Support Bot', + defaultDescription: 'An agent built with n8n.', + }); + + const { queryByTestId, getByTestId } = renderView({ + props: { + ...viewProps, + runtime: managedRuntime({ + catalogState: ref({ status: 'published', teamsAppId: 'teams-app-1' }), + }), + }, + pinia: createTestingPinia(), + }); + + await waitFor(() => expect(getByTestId('teams-identity-field')).toBeVisible()); + expect(queryByTestId('teams-managed-app')).not.toBeInTheDocument(); + }); + + /** + * Publishing reaches every person in the organisation, or asks an + * administrator to approve it. A save must not do either on the user's + * behalf, even where the app is published already and a republish is the + * step they most likely want next. + */ + it('does not publish when a managed channel is saved', async () => { + const publishApp = vi.fn(); + const { getByTestId } = renderHost({ + props: { + props: { + ...viewProps, + savedSettings: { teamChannels: false }, + runtime: managedRuntime({ + catalogState: ref({ status: 'published', teamsAppId: 'teams-app-1' }), + publishApp, + }), + }, + }, + pinia: createTestingPinia(), + }); + + await waitFor(() => expect(getByTestId('teams-availability')).toBeVisible()); + await fireEvent.click( + within(getByTestId('teams-availability')).getByLabelText(`Toggle ${WHERE_TITLE}`), + ); + await waitFor(() => expect(getByTestId('teams-scope-channels')).toBeVisible()); + await fireEvent.click(getByTestId('teams-scope-channels')); + + await fireEvent.click(getByTestId('save')); + await flushPromises(); + + expect(publishApp).not.toHaveBeenCalled(); + }); + + /** + * The bug this guards: n8n built the app for both install routes, so + * provenance alone says nothing about whether anybody published. A channel + * somebody installed only for themselves must stay that way. + */ + it.each([ + ['nothing read back yet', null], + ['an app the catalogue does not list', { status: 'unknown' as const, teamsAppId: null }], + ])('does not publish a channel n8n made but nobody published, with %s', async (_name, state) => { + const publishApp = vi.fn(); + const { getByTestId } = renderHost({ + props: { + props: { + ...viewProps, + savedSettings: { teamChannels: false }, + runtime: managedRuntime({ catalogState: ref(state), publishApp }), + }, + }, + pinia: createTestingPinia(), + }); + + // The card is right to be here -- only the save was wrong. + await waitFor(() => expect(getByTestId('teams-managed-app')).toBeVisible()); + await fireEvent.click( + within(getByTestId('teams-availability')).getByLabelText(`Toggle ${WHERE_TITLE}`), + ); + await waitFor(() => expect(getByTestId('teams-scope-channels')).toBeVisible()); + await fireEvent.click(getByTestId('teams-scope-channels')); + + expect(getByTestId('save-label')).toBeEmptyDOMElement(); + + await fireEvent.click(getByTestId('save')); + await flushPromises(); + + expect(publishApp).not.toHaveBeenCalled(); + }); + + /** + * A channel wired up by hand has no app n8n can publish, so the managed card + * must stay away and the package must stay the whole answer. + */ + it('offers the package and nothing else to a hand-made channel', async () => { + const { getByTestId, queryByTestId } = renderView({ props: viewProps, pinia: createTestingPinia(), }); await waitFor(() => expect(getByTestId('teams-download-package')).toBeVisible()); + expect(queryByTestId('teams-managed-app')).not.toBeInTheDocument(); + expect(queryByTestId('teams-settings-publish')).not.toBeInTheDocument(); }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsEditView.vue b/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsEditView.vue index 5a14d38d896..c0dca4a2633 100644 --- a/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsEditView.vue +++ b/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsEditView.vue @@ -4,9 +4,12 @@ import { computed, ref } from 'vue'; import AgentChannelTeamsSetup from './AgentChannelTeamsSetup.vue'; import AgentChannelStandardEditView from '../AgentChannelStandardEditView.vue'; import type { AgentChannelViewExpose, AgentChannelViewProps } from '../types'; +import type { TeamsChannelRuntime } from './useTeamsChannelRuntime'; const credentialId = defineModel({ default: '' }); -defineProps(); +// Narrowed from `AgentChannelRuntime`: the settings reach for the Teams-only +// parts of it to offer publishing, which the shared type does not carry. +defineProps & { runtime: TeamsChannelRuntime }>(); const emit = defineEmits<{ create: []; edit: []; diff --git a/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsManagedApp.test.ts b/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsManagedApp.test.ts new file mode 100644 index 00000000000..ac95904e40e --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsManagedApp.test.ts @@ -0,0 +1,285 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import type { TeamsCatalogState } from '@n8n/api-types'; +import { createTestingPinia } from '@pinia/testing'; +import { configure, fireEvent, waitFor } from '@testing-library/vue'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { computed, ref } from 'vue'; + +import { fetchTeamsAppPackage } from './api'; +import AgentChannelTeamsManagedApp from './AgentChannelTeamsManagedApp.vue'; +import type { TeamsChannelRuntime } from './useTeamsChannelRuntime'; + +vi.mock('file-saver', () => ({ saveAs: vi.fn() })); + +vi.mock('./api', () => ({ + fetchTeamsAppPackage: vi.fn().mockResolvedValue(new Blob()), +})); + +vi.mock('@n8n/i18n', async (importOriginal) => ({ + ...(await importOriginal()), + useI18n: () => ({ baseText: (key: string) => key }), +})); + +// The shared default is `data-test-id`; these components use `data-testid`. +configure({ testIdAttribute: 'data-testid' }); + +const renderComponent = createComponentRenderer(AgentChannelTeamsManagedApp); + +const buildRuntime = (overrides: Partial = {}): TeamsChannelRuntime => + ({ + managedSetup: ref({ + managedSetupAvailable: true, + managerCredentials: [], + adminConsentUrl: null, + }), + setupKind: ref('managed'), + managerCredentialId: ref('manager-1'), + loading: computed(() => false), + load: vi.fn(), + connectManagerCredential: vi.fn(), + editManagerCredential: vi.fn(), + provisionedApp: ref(null), + botSetupState: ref(null), + provisionedBot: ref(null), + subscriptions: ref([]), + catalogState: ref(null), + connectedCredentialId: ref(''), + installed: ref(false), + provisionApp: vi.fn(), + loadSubscriptions: vi.fn(), + provisionBot: vi.fn(), + publishApp: vi.fn(), + refreshCatalogState: vi.fn(), + checkInstalled: vi.fn().mockResolvedValue(false), + ...overrides, + }) as unknown as TeamsChannelRuntime; + +const render = (overrides: Record = {}) => { + const { runtime, ...rest } = overrides as { runtime?: TeamsChannelRuntime }; + return renderComponent({ + props: { + runtime: runtime ?? buildRuntime(), + name: 'Support Bot', + description: 'Answers support questions', + credentialId: 'bot-cred-1', + projectId: 'project-1', + agentId: 'agent-1', + ...rest, + }, + pinia: createTestingPinia(), + }); +}; + +describe('AgentChannelTeamsManagedApp', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + /** Every call the card makes acts on the app that credential stands for. */ + it('hands the channel credential to the runtime before reading anything', async () => { + const runtime = buildRuntime(); + // Captured inside the read: asserting the ref afterwards passes even if + // the read went out before the credential was assigned. + let credentialAtRead: string | undefined; + runtime.refreshCatalogState = vi.fn(async () => { + credentialAtRead = runtime.connectedCredentialId.value; + }); + + render({ runtime }); + + await waitFor(() => expect(runtime.refreshCatalogState).toHaveBeenCalled()); + expect(credentialAtRead).toBe('bot-cred-1'); + }); + + it('says where the app stands once the catalogue answers', async () => { + const runtime = buildRuntime({ + catalogState: ref({ status: 'submitted', teamsAppId: 'teams-app-1' }), + }); + + const { getByTestId } = render({ runtime }); + + await waitFor(() => + expect(getByTestId('teams-managed-app-standing')).toHaveTextContent( + 'agents.channels.teams.managed.app.submitted', + ), + ); + }); + + /** A review is the one state that cannot take another submission. */ + it('offers no publish while a review is pending', async () => { + const runtime = buildRuntime({ + catalogState: ref({ status: 'submitted', teamsAppId: 'teams-app-1' }), + }); + + const { queryByTestId } = render({ runtime }); + + await waitFor(() => expect(queryByTestId('teams-settings-publish')).not.toBeInTheDocument()); + }); + + it('publishes again, because that is how a changed manifest reaches the catalogue', async () => { + const runtime = buildRuntime({ + catalogState: ref({ status: 'published', teamsAppId: 'teams-app-1' }), + }); + const settings = { availableIn: 'personal' } as never; + + const { getByTestId } = render({ runtime, settings }); + + // The card reads the catalogue on mount, and holds the action until it has. + await fireEvent.click(getByTestId('teams-settings-publish')); + + await waitFor(() => expect(runtime.publishApp).toHaveBeenCalledWith(settings)); + }); + + /** + * The save in the footer closes the dialog, so waiting for it would take the + * user away from the card. Publishing saves the open change itself, and the + * button stays available to say so. + */ + it('saves an unsaved change before it publishes', async () => { + const runtime = buildRuntime({ + catalogState: ref({ status: 'published', teamsAppId: 'teams-app-1' }), + }); + const persistChannel = vi.fn().mockResolvedValue(true); + + const { getByTestId } = render({ runtime, unsavedChanges: true, persistChannel }); + + await waitFor(() => + expect(getByTestId('teams-settings-publish')).not.toHaveAttribute('disabled'), + ); + await fireEvent.click(getByTestId('teams-settings-publish')); + + await waitFor(() => expect(runtime.publishApp).toHaveBeenCalled()); + // Saved first: the catalogue must never hold a version n8n does not. + expect(persistChannel.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(runtime.publishApp).mock.invocationCallOrder[0], + ); + }); + + /** + * The form can move while the save is away. Publishing what the form held + * when the button was pressed keeps the catalogue at or behind n8n; reading + * it afterwards could put an unsaved edit in the organisation's catalogue. + */ + it('publishes what the form held when the save started', async () => { + const runtime = buildRuntime({ + catalogState: ref({ status: 'published', teamsAppId: 'teams-app-1' }), + }); + const { rerender, getByTestId } = render({ + runtime, + unsavedChanges: true, + pendingSettings: { teamChannels: true }, + persistChannel: vi.fn().mockResolvedValue(true), + }); + + await waitFor(() => + expect(getByTestId('teams-settings-publish')).not.toHaveAttribute('disabled'), + ); + const publishing = fireEvent.click(getByTestId('teams-settings-publish')); + // The user keeps editing while the save is in flight. + await rerender({ pendingSettings: { teamChannels: true, groupChats: true } }); + await publishing; + + await waitFor(() => expect(runtime.publishApp).toHaveBeenCalledWith({ teamChannels: true })); + }); + + // The package is what the channel runs on, so an open edit is not in it. + it('builds the package from the saved settings, not an open edit', async () => { + const runtime = buildRuntime({ + catalogState: ref({ status: 'published', teamsAppId: 'teams-app-1' }), + }); + const { getByTestId } = render({ + runtime, + unsavedChanges: true, + settings: { teamChannels: false }, + pendingSettings: { teamChannels: true }, + }); + + await waitFor(() => + expect(getByTestId('teams-settings-download')).not.toHaveAttribute('disabled'), + ); + await fireEvent.click(getByTestId('teams-settings-download')); + + await waitFor(() => + expect(fetchTeamsAppPackage).toHaveBeenCalledWith( + expect.anything(), + 'project-1', + 'agent-1', + 'bot-cred-1', + { teamChannels: false }, + ), + ); + }); + + /** + * A publish that landed on top of a failed save would put the catalogue + * ahead of n8n, which is worse than leaving it behind. + */ + it('publishes nothing when the save it depends on fails', async () => { + const runtime = buildRuntime({ + catalogState: ref({ status: 'published', teamsAppId: 'teams-app-1' }), + }); + const persistChannel = vi.fn().mockResolvedValue(false); + + const { getByTestId } = render({ runtime, unsavedChanges: true, persistChannel }); + + await waitFor(() => + expect(getByTestId('teams-settings-publish')).not.toHaveAttribute('disabled'), + ); + await fireEvent.click(getByTestId('teams-settings-publish')); + + await waitFor(() => expect(persistChannel).toHaveBeenCalled()); + expect(runtime.publishApp).not.toHaveBeenCalled(); + }); + + // A clean form has nothing to save, so the publish goes straight out. + it('publishes a saved channel without saving again', async () => { + const runtime = buildRuntime({ + catalogState: ref({ status: 'published', teamsAppId: 'teams-app-1' }), + }); + const persistChannel = vi.fn().mockResolvedValue(true); + + const { getByTestId } = render({ runtime, unsavedChanges: false, persistChannel }); + + await waitFor(() => + expect(getByTestId('teams-settings-publish')).not.toHaveAttribute('disabled'), + ); + await fireEvent.click(getByTestId('teams-settings-publish')); + + await waitFor(() => expect(runtime.publishApp).toHaveBeenCalled()); + expect(persistChannel).not.toHaveBeenCalled(); + }); + + /** + * A read that never answers must not take the action away with it: the + * publish is the whole point of the card. + */ + it('still offers a publish when the catalogue could not be read', async () => { + const runtime = buildRuntime({ + refreshCatalogState: vi.fn().mockRejectedValue(new Error('Graph refused the read')), + }); + + const { getByTestId } = render({ runtime }); + + await waitFor(() => + expect(getByTestId('teams-managed-app-standing')).toHaveTextContent( + 'agents.channels.teams.managed.app.unknown', + ), + ); + expect(getByTestId('teams-settings-publish')).not.toHaveAttribute('disabled'); + expect(getByTestId('teams-managed-app-error')).toHaveTextContent('Graph refused the read'); + }); + + /** + * Uploading the package is the only route that works through the day + * Microsoft can take to make a published app addable. + */ + it('offers the package whatever the catalogue says', async () => { + const runtime = buildRuntime({ + catalogState: ref({ status: 'submitted', teamsAppId: 'teams-app-1' }), + }); + + const { getByTestId } = render({ runtime }); + + await waitFor(() => expect(getByTestId('teams-settings-download')).toBeVisible()); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsManagedApp.vue b/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsManagedApp.vue new file mode 100644 index 00000000000..f0b3632ab7e --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsManagedApp.vue @@ -0,0 +1,230 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsManagedSetup.test.ts b/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsManagedSetup.test.ts index efb66bebb67..de8dd2baea7 100644 --- a/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsManagedSetup.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsManagedSetup.test.ts @@ -1,5 +1,5 @@ import { createComponentRenderer } from '@/__tests__/render'; -import type { TeamsManagedSetupState } from '@n8n/api-types'; +import type { TeamsCatalogState, TeamsManagedSetupState } from '@n8n/api-types'; import { createTestingPinia } from '@pinia/testing'; import { configure, fireEvent, waitFor, within } from '@testing-library/vue'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -61,10 +61,14 @@ const buildRuntime = (overrides: Partial = {}): TeamsChanne botSetupState: ref(null), provisionedBot: ref(null), subscriptions: ref([]), + catalogState: ref(null), + connectedCredentialId: ref(''), provisionApp: vi.fn().mockResolvedValue(true), loadSubscriptions: vi.fn().mockResolvedValue(true), provisionBot: vi.fn().mockResolvedValue(true), reportManualBotRoute: vi.fn().mockResolvedValue(undefined), + publishApp: vi.fn().mockResolvedValue(true), + refreshCatalogState: vi.fn(), ...overrides, }); @@ -327,6 +331,8 @@ describe('AgentChannelTeamsManagedSetup', () => { resourceGroup: 'n8n-agents', subscriptionId: 'sub-1', }), + catalogState: ref(null), + publishApp: vi.fn().mockResolvedValue(true), }), ); const { getByTestId } = createComponentRenderer(Host, { @@ -335,8 +341,7 @@ describe('AgentChannelTeamsManagedSetup', () => { await waitFor(() => expect(getByTestId('can-finish')).toHaveTextContent('false')); - // Handing over the package is what persists the channel here. - await fireEvent.click(getByTestId('teams-install-download-package')); + await fireEvent.click(getByTestId('teams-publish')); await waitFor(() => expect(getByTestId('can-finish')).toHaveTextContent('true')); }); @@ -461,6 +466,37 @@ describe('AgentChannelTeamsManagedSetup', () => { expect(runtime.loadSubscriptions).toHaveBeenCalledTimes(2); }); + /** + * `refreshCatalogState` refuses to let an `unknown` read overwrite a known + * state, so a verdict left over from the previous tenant does not merely + * flicker -- it survives the new tenant's read. + */ + it('forgets where the app stood when the account changes', async () => { + const otherCredential = { ...connectedCredential, id: 'cred-2', name: 'Other organization' }; + const runtime = buildRuntime({ + catalogState: ref({ + status: 'published', + teamsAppId: 'teams-app-1', + }), + }); + const { rerender } = render({ + modelValue: 'cred-1', + setup: setupState({ managerCredentials: [connectedCredential, otherCredential] }), + runtime, + }); + await waitFor(() => expect(runtime.loadSubscriptions).toHaveBeenCalled()); + + await rerender( + props({ + modelValue: 'cred-2', + setup: setupState({ managerCredentials: [connectedCredential, otherCredential] }), + runtime, + }), + ); + + await waitFor(() => expect(runtime.catalogState.value).toBeNull()); + }); + /** The subscription is the bot step's problem, and it says so there. */ it('says nothing about Azure before the app is registered', async () => { const { getByTestId, queryByTestId } = render(signedIn({ subscriptions: ref([]) })); @@ -601,6 +637,7 @@ describe('AgentChannelTeamsManagedSetup', () => { botId: 'app-1', deployToAzureUrl: 'https://portal.azure.com/template', credentialClaimedBy: null, + provisionedByN8n: true, defaultDisplayName: 'Support Bot', defaultDescription: 'An agent', }), @@ -709,6 +746,7 @@ describe('AgentChannelTeamsManagedSetup', () => { resourceGroup: 'n8n-agents', subscriptionId: 'sub-1', }), + catalogState: ref({ status: 'submitted', teamsAppId: 'teams-app-1' }), }), ); @@ -911,6 +949,223 @@ describe('AgentChannelTeamsManagedSetup', () => { expect(runtime.setupKind.value).toBe('manual'); expect(runtime.reportManualBotRoute).not.toHaveBeenCalled(); }); + + it('sends a rejected app to the Teams admin centre, inventing no reason', async () => { + const { getByTestId } = render( + signedIn({ + provisionedApp: ref({ + credentialId: 'bot-cred-1', + appId: 'app-1', + appName: 'Support Bot (n8n)', + organizationName: 'Acme Corp', + entraAppUrl: 'https://entra.microsoft.com/app-1', + secretExpiresAt: '2028-09-17T00:00:00Z', + }), + provisionedBot: ref({ + botName: 'support-bot-1', + resourceGroup: 'n8n-agents', + subscriptionId: 'sub-1', + }), + catalogState: ref({ status: 'rejected', teamsAppId: 'teams-app-1' }), + }), + ); + + // Microsoft supplies no reason field, so the copy must point at the + // admin centre rather than guess at one. + await waitFor(() => + expect(getByTestId('teams-catalog-state')).toHaveTextContent( + 'agents.channels.teams.managed.install.rejected', + ), + ); + }); + + /** + * Publishing and installing are sequential, not alternatives. Once the app + * is in the catalogue the only thing left is adding it, so that is the only + * thing the step offers. + */ + /** + * The catalogue answers "not listed yet" about an app it is still being + * given, so that line beside a running publish reads as the answer to + * that publish -- and it was shown before the user had published at all. + */ + it('says nothing about the catalogue while it is being asked', async () => { + let finishPublish = () => {}; + const { getByTestId, queryByTestId } = render( + signedIn({ + provisionedApp: ref({ + credentialId: 'bot-cred-1', + appId: 'app-1', + appName: 'Support Bot (n8n)', + organizationName: 'Acme Corp', + entraAppUrl: 'https://entra.microsoft.com/app-1', + secretExpiresAt: '2028-09-17T00:00:00Z', + }), + provisionedBot: ref({ + botName: 'support-bot-1', + resourceGroup: 'n8n-agents', + subscriptionId: 'sub-1', + }), + catalogState: ref({ status: 'unknown', teamsAppId: null }), + publishApp: vi.fn( + async () => + await new Promise((resolve) => (finishPublish = () => resolve(true))), + ), + }), + ); + + await fireEvent.click(getByTestId('teams-publish')); + + await waitFor(() => expect(getByTestId('teams-install-progress')).toBeVisible()); + expect(queryByTestId('teams-catalog-state')).not.toBeInTheDocument(); + + finishPublish(); + await waitFor(() => expect(getByTestId('teams-catalog-state')).toBeVisible()); + }); + + /** + * Publishing is the end of n8n's part, so the step closes on it. The + * package stays reachable because the published app is in nobody's Teams + * until Microsoft lets it be added. + */ + /** + * Microsoft keeps the app through a disconnect, so a re-run publishes + * nothing and hands over no package -- neither of the routes that writes + * the channel runs. Without Done the credential can never be bound back + * to the agent, which is the state this lands in. + */ + it('offers Done for an app the catalogue already has', async () => { + const Host = defineComponent({ + components: { AgentChannelTeamsManagedSetup }, + props: { hostProps: { type: Object, required: true } }, + setup: () => ({ view: ref<{ canFinish?: boolean }>() }), + template: ` +
+ + {{ String(view?.canFinish) }} +
+ `, + }); + const hostProps = props( + signedIn({ + provisionedApp: ref({ + credentialId: 'bot-cred-1', + appId: 'app-1', + appName: 'Support Bot (n8n)', + organizationName: 'Acme Corp', + entraAppUrl: 'https://entra.microsoft.com/app-1', + secretExpiresAt: '2028-09-17T00:00:00Z', + }), + provisionedBot: ref({ + botName: 'support-bot-1', + resourceGroup: 'n8n-agents', + subscriptionId: 'sub-1', + }), + catalogState: ref({ status: 'published', teamsAppId: 'teams-app-1' }), + }), + ); + const { getByTestId } = createComponentRenderer(Host, { global: { stubs } })({ + props: { hostProps }, + pinia: createTestingPinia(), + }); + + // Nothing was downloaded and nothing published in this session. + await waitFor(() => expect(getByTestId('can-finish')).toHaveTextContent('true')); + }); + + it('moves to the last step on a publish, and keeps the package reachable', async () => { + const catalogState = ref({ status: 'unknown', teamsAppId: null }); + const { getByTestId, queryByTestId } = render( + signedIn({ + provisionedApp: ref({ + credentialId: 'bot-cred-1', + appId: 'app-1', + appName: 'Support Bot (n8n)', + organizationName: 'Acme Corp', + entraAppUrl: 'https://entra.microsoft.com/app-1', + secretExpiresAt: '2028-09-17T00:00:00Z', + }), + provisionedBot: ref({ + botName: 'support-bot-1', + resourceGroup: 'n8n-agents', + subscriptionId: 'sub-1', + }), + catalogState, + publishApp: vi.fn(async () => { + catalogState.value = { status: 'published', teamsAppId: 'teams-app-1' }; + return true; + }), + }), + ); + + await fireEvent.click(getByTestId('teams-publish')); + + // The last step says where the app stands, and keeps the package on + // offer: a published app is in nobody's Teams until Microsoft lists it. + await waitFor(() => + expect(getByTestId('teams-finish-published')).toHaveTextContent( + 'agents.channels.teams.managed.finish.published', + ), + ); + expect(getByTestId('teams-finish-download-package')).toBeVisible(); + // Nothing was added to anyone's Teams, so nothing says it was. + expect(queryByTestId('teams-finish-instructions')).not.toBeInTheDocument(); + }); + + it('copies an approval request once the app is in for review', async () => { + const { getByTestId } = render( + signedIn({ + provisionedApp: ref({ + credentialId: 'bot-cred-1', + appId: 'app-1', + appName: 'Support Bot (n8n)', + organizationName: 'Acme Corp', + entraAppUrl: 'https://entra.microsoft.com/app-1', + secretExpiresAt: '2028-09-17T00:00:00Z', + }), + provisionedBot: ref({ + botName: 'support-bot-1', + resourceGroup: 'n8n-agents', + subscriptionId: 'sub-1', + }), + catalogState: ref({ status: 'submitted', teamsAppId: 'teams-app-1' }), + }), + ); + + await fireEvent.click(getByTestId('teams-copy-approval-request')); + + await waitFor(() => expect(copySpy).toHaveBeenCalled()); + const [copied] = copySpy.mock.calls[0] as [string]; + expect(copied).toContain('Support Bot (n8n)'); + expect(copied).toContain('https://admin.teams.microsoft.com/policies/manage-apps'); + }); + + it('shows where the app stands in the catalogue', async () => { + const { getByTestId } = render( + signedIn({ + provisionedApp: ref({ + credentialId: 'bot-cred-1', + appId: 'app-1', + appName: 'Support Bot (n8n)', + organizationName: 'Acme Corp', + entraAppUrl: 'https://entra.microsoft.com/app-1', + secretExpiresAt: '2028-09-17T00:00:00Z', + }), + provisionedBot: ref({ + botName: 'support-bot-1', + resourceGroup: 'n8n-agents', + subscriptionId: 'sub-1', + }), + catalogState: ref({ status: 'submitted', teamsAppId: 'teams-app-1' }), + }), + ); + + await waitFor(() => + expect(getByTestId('teams-catalog-state')).toHaveTextContent( + 'agents.channels.teams.managed.install.submitted', + ), + ); + }); }); it('selects a connected sign-in by itself', async () => { diff --git a/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsManagedSetup.vue b/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsManagedSetup.vue index 17a521b3916..0f8ac8f7aec 100644 --- a/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsManagedSetup.vue +++ b/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsManagedSetup.vue @@ -1,15 +1,20 @@