mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(core): Publish the Microsoft Teams app to the organisation (no-changelog) (#40697)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b09efb706c
commit
60e0cb1e7b
|
|
@ -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: ''`).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TeamsManagedSetupService>(),
|
||||
mock<TeamsEntraProvisioningService>(),
|
||||
mock<TeamsBotProvisioningService>(),
|
||||
mock<TeamsCatalogService>(),
|
||||
mock<TeamsSetupTelemetryService>(),
|
||||
),
|
||||
};
|
||||
|
|
@ -144,6 +149,7 @@ describe('AgentTeamsIntegrationsController', () => {
|
|||
mock<TeamsManagedSetupService>(),
|
||||
mock<TeamsEntraProvisioningService>(),
|
||||
botProvisioningService,
|
||||
mock<TeamsCatalogService>(),
|
||||
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<TeamsCatalogService>();
|
||||
const setupTelemetry = mock<TeamsSetupTelemetryService>();
|
||||
return {
|
||||
catalogService,
|
||||
setupTelemetry,
|
||||
controller: new AgentTeamsIntegrationsController(
|
||||
mock<TeamsSetupService>(),
|
||||
mock<TeamsCredentialCheckService>(),
|
||||
mock<TeamsManagedSetupService>(),
|
||||
mock<TeamsEntraProvisioningService>(),
|
||||
mock<TeamsBotProvisioningService>(),
|
||||
catalogService,
|
||||
setupTelemetry,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const request = () =>
|
||||
mock<AuthenticatedRequest<{ projectId: string }>>({
|
||||
params: { projectId: 'project-1' },
|
||||
user: mock<User>({ 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<Response>(), '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<Response>(), '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<Response>(), '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<Response>(), '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<User>({ id: 'user-1' }),
|
||||
});
|
||||
|
||||
await expect(controller.getCatalogState(req, mock<Response>(), '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<User>({ id: 'user-1' }),
|
||||
});
|
||||
|
||||
await expect(controller.getCatalogState(req, mock<Response>(), 'agent-1')).resolves.toEqual({
|
||||
status: 'unknown',
|
||||
teamsAppId: null,
|
||||
});
|
||||
|
||||
expect(catalogService.getState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<TeamsCatalogState> {
|
||||
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<TeamsCatalogState> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<User>({ 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<typeof mock<TeamsGraphService>>;
|
||||
let tokens: ReturnType<typeof mock<TeamsManagerTokenService>>;
|
||||
let setupService: ReturnType<typeof mock<TeamsSetupService>>;
|
||||
let credentialsFinderService: ReturnType<typeof mock<CredentialsFinderService>>;
|
||||
let credentialsService: ReturnType<typeof mock<CredentialsService>>;
|
||||
let service: TeamsCatalogService;
|
||||
|
||||
/** Makes the channel credential readable, carrying `data`. */
|
||||
const withBotCredential = (data: Record<string, unknown>) => {
|
||||
credentialsFinderService.findCredentialForUser.mockImplementation((async (id: string) =>
|
||||
mock<CredentialsEntity>({
|
||||
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<TeamsGraphService>();
|
||||
tokens = mock<TeamsManagerTokenService>();
|
||||
setupService = mock<TeamsSetupService>();
|
||||
credentialsFinderService = mock<CredentialsFinderService>();
|
||||
credentialsService = mock<CredentialsService>();
|
||||
const manifestService = mock<TeamsManifestService>();
|
||||
manifestService.buildManifestId.mockReturnValue(EXTERNAL_ID);
|
||||
|
||||
service = new TeamsCatalogService(
|
||||
graph,
|
||||
tokens,
|
||||
setupService,
|
||||
manifestService,
|
||||
credentialsFinderService,
|
||||
credentialsService,
|
||||
mock<Logger>(),
|
||||
);
|
||||
|
||||
tokens.acquire.mockResolvedValue('graph-token');
|
||||
setupService.buildPackage.mockResolvedValue(ARCHIVE);
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValue(
|
||||
mock<CredentialsEntity>({
|
||||
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<CredentialsEntity>({
|
||||
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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<TeamsCatalogState> {
|
||||
const state = await this.publishToCatalog(options);
|
||||
await this.rememberPublish(options, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
private async publishToCatalog(options: PublishTeamsAppOptions): Promise<TeamsCatalogState> {
|
||||
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<TeamsCatalogState> {
|
||||
// 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<TeamsCatalogState> {
|
||||
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<void> {
|
||||
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<TeamsCatalogState | undefined> {
|
||||
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<CredentialsEntity | undefined> {
|
||||
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<CatalogEntry | undefined> {
|
||||
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<CatalogEntry | undefined> {
|
||||
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<CatalogEntry | undefined> {
|
||||
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<TeamsCatalogState> {
|
||||
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<string> {
|
||||
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<CredentialsEntity> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -425,7 +425,35 @@ export class TeamsEntraProvisioningService {
|
|||
tenantId: string;
|
||||
existingCredential?: CredentialsEntity;
|
||||
}): Promise<string> {
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -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<GraphResponse> {
|
||||
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 } }`. */
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ export const RESOURCE_SCOPES: Record<ManagerResource, string> = {
|
|||
[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(' '),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {}) =>
|
||||
({
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string>({ default: '' });
|
||||
defineProps<AgentChannelViewProps>();
|
||||
// Narrowed from `AgentChannelRuntime`: the settings reach for the Teams-only
|
||||
// parts of it to offer publishing, which the shared type does not carry.
|
||||
defineProps<Omit<AgentChannelViewProps, 'runtime'> & { runtime: TeamsChannelRuntime }>();
|
||||
const emit = defineEmits<{
|
||||
create: [];
|
||||
edit: [];
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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<TeamsCatalogState | null>(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<string, unknown> = {}) => {
|
||||
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());
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Where the app stands in the organisation, and the one thing to do about it,
|
||||
* for a channel that is already connected.
|
||||
*
|
||||
* Microsoft takes up to a day to make a published app installable, so a setup
|
||||
* that publishes and cannot add it yet is ordinary rather than failed. The
|
||||
* setup stepper is gone by then — it closes on connect — so without this the
|
||||
* only step left in the flow had nowhere to happen.
|
||||
*/
|
||||
import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
|
||||
import type { AgentTeamsIntegrationSettings } from '@n8n/api-types';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import AgentChannelTeamsIdentityCard from './AgentChannelTeamsIdentityCard.vue';
|
||||
import { fetchTeamsAppPackage } from './api';
|
||||
import { TEAMS_PACKAGE_FILENAME } from './constants';
|
||||
import type { TeamsChannelRuntime } from './useTeamsChannelRuntime';
|
||||
|
||||
const props = defineProps<{
|
||||
runtime: TeamsChannelRuntime;
|
||||
name: string;
|
||||
description: string;
|
||||
/** The channel's own credential — the one the Entra step wrote. */
|
||||
credentialId: string;
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
/** What the channel runs on. The package hands over this, not an open edit. */
|
||||
settings?: AgentTeamsIntegrationSettings;
|
||||
/**
|
||||
* What the form holds. Publishing saves it first and sends this, captured
|
||||
* before the save: `settings` catches up a round trip later, and an edit made
|
||||
* while the save is away is not what was persisted.
|
||||
*/
|
||||
pendingSettings?: AgentTeamsIntegrationSettings;
|
||||
/**
|
||||
* Whether the form holds a manifest change that is not saved yet. Publishing
|
||||
* saves it first, so the catalogue never gets a version n8n does not hold.
|
||||
*/
|
||||
unsavedChanges?: boolean;
|
||||
/**
|
||||
* Saves the channel without closing the modal. Publishing an unsaved change
|
||||
* goes through this, so the user reaches the catalogue in one step and stays
|
||||
* on the dialog that reports how it went.
|
||||
*/
|
||||
persistChannel?: () => Promise<boolean>;
|
||||
}>();
|
||||
|
||||
type Busy = 'publish' | 'download';
|
||||
|
||||
const i18n = useI18n();
|
||||
const rootStore = useRootStore();
|
||||
const busy = ref<Busy | null>(null);
|
||||
const errorMessage = ref('');
|
||||
const downloaded = ref(false);
|
||||
// The catalogue read runs on its own, so it takes no lock: an action the user
|
||||
// chose must not wait on a background read, nor vanish when one fails.
|
||||
const reading = ref(false);
|
||||
const readFailed = ref(false);
|
||||
|
||||
const catalogState = computed(() => props.runtime.catalogState.value);
|
||||
const published = computed(() => catalogState.value?.status === 'published');
|
||||
|
||||
/**
|
||||
* One sentence for where the app is, so the action below reads as the next
|
||||
* step. Nothing is claimed until the catalogue has answered: the read happens
|
||||
* on mount, and until it lands an app already published would otherwise be
|
||||
* announced as one nobody has published.
|
||||
*/
|
||||
const standing = computed(() => {
|
||||
if (!catalogState.value) {
|
||||
return readFailed.value
|
||||
? ('agents.channels.teams.managed.app.unknown' as const)
|
||||
: ('agents.channels.teams.managed.app.checking' as const);
|
||||
}
|
||||
if (reading.value) return 'agents.channels.teams.managed.app.checking' as const;
|
||||
if (published.value) return 'agents.channels.teams.managed.app.published' as const;
|
||||
if (catalogState.value?.status === 'submitted') {
|
||||
return 'agents.channels.teams.managed.app.submitted' as const;
|
||||
}
|
||||
if (catalogState.value?.status === 'rejected') {
|
||||
return 'agents.channels.teams.managed.app.rejected' as const;
|
||||
}
|
||||
return 'agents.channels.teams.managed.app.notPublished' as const;
|
||||
});
|
||||
|
||||
async function run(name: Busy, action: () => Promise<void>) {
|
||||
if (busy.value) return;
|
||||
busy.value = name;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
busy.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
const publish = async () =>
|
||||
await run('publish', async () => {
|
||||
// Captured before the save: read after it, an edit made while the save was
|
||||
// away would go to the catalogue without ever being persisted.
|
||||
const publishing = props.pendingSettings ?? props.settings;
|
||||
// Saved first, and abandoned if that fails: a publish that lands on top of
|
||||
// a failed save would leave the catalogue ahead of n8n, which is worse
|
||||
// than leaving it behind.
|
||||
if (props.unsavedChanges && !(await props.persistChannel?.())) return;
|
||||
await props.runtime.publishApp(publishing);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Offered throughout rather than only once an add has been refused.
|
||||
*/
|
||||
const downloadPackage = async () =>
|
||||
await run('download', async () => {
|
||||
const blob = await fetchTeamsAppPackage(
|
||||
rootStore.restApiContext,
|
||||
props.projectId,
|
||||
props.agentId,
|
||||
props.credentialId,
|
||||
props.settings,
|
||||
);
|
||||
saveAs(blob, TEAMS_PACKAGE_FILENAME);
|
||||
downloaded.value = true;
|
||||
});
|
||||
|
||||
/** Read once, when the settings open: the state is Microsoft's and it moves. */
|
||||
async function readCatalogue() {
|
||||
reading.value = true;
|
||||
readFailed.value = false;
|
||||
try {
|
||||
await props.runtime.refreshCatalogState();
|
||||
} catch (error) {
|
||||
readFailed.value = true;
|
||||
errorMessage.value = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
reading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// The credential goes first: every call below acts on the app it stands for.
|
||||
onMounted(() => {
|
||||
props.runtime.connectedCredentialId.value = props.credentialId;
|
||||
void readCatalogue();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.credentialId,
|
||||
(id) => (props.runtime.connectedCredentialId.value = id),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.field" data-testid="teams-managed-app">
|
||||
<N8nText size="small" bold>
|
||||
{{ i18n.baseText('agents.channels.teams.settings.identityLabel') }}
|
||||
</N8nText>
|
||||
|
||||
<AgentChannelTeamsIdentityCard :name="name" :description="description" ready>
|
||||
<template #action>
|
||||
<!--
|
||||
Offered once the app is published too: publishing again is how a
|
||||
changed manifest reaches the catalogue, and it saves an open change
|
||||
on the way. Hidden only while a review is pending, which is the one
|
||||
state that cannot take another -- and shown while the catalogue is
|
||||
unread, because a read that never answers must not take the action
|
||||
away with it.
|
||||
-->
|
||||
<N8nButton
|
||||
v-if="catalogState?.status !== 'submitted'"
|
||||
variant="outline"
|
||||
size="medium"
|
||||
:loading="busy === 'publish'"
|
||||
:disabled="busy !== null"
|
||||
data-testid="teams-settings-publish"
|
||||
@click="publish"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.managed.install.publish') }}
|
||||
</N8nButton>
|
||||
<!--
|
||||
Always offered. Uploading it is the only way to use the agent
|
||||
through the day Microsoft can take to make the published app
|
||||
addable.
|
||||
-->
|
||||
<N8nButton
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
:loading="busy === 'download'"
|
||||
:disabled="busy !== null"
|
||||
data-testid="teams-settings-download"
|
||||
@click="downloadPackage"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.install.button') }}
|
||||
<N8nIcon icon="download" size="medium" />
|
||||
</N8nButton>
|
||||
</template>
|
||||
</AgentChannelTeamsIdentityCard>
|
||||
|
||||
<N8nText size="small" color="text-light" data-testid="teams-managed-app-standing">
|
||||
{{ i18n.baseText(standing) }}
|
||||
</N8nText>
|
||||
|
||||
<N8nText
|
||||
v-if="downloaded"
|
||||
size="small"
|
||||
color="text-light"
|
||||
data-testid="teams-managed-app-downloaded"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.managed.install.downloaded') }}
|
||||
</N8nText>
|
||||
|
||||
<N8nText v-if="errorMessage" size="small" color="danger" data-testid="teams-managed-app-error">
|
||||
{{ errorMessage }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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<TeamsChannelRuntime> = {}): 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<TeamsCatalogState | null>(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<TeamsCatalogState | null>({
|
||||
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<TeamsCatalogState | null>({ status: 'unknown', teamsAppId: null }),
|
||||
publishApp: vi.fn(
|
||||
async () =>
|
||||
await new Promise<boolean>((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: `
|
||||
<div>
|
||||
<AgentChannelTeamsManagedSetup ref="view" v-bind="hostProps" />
|
||||
<span data-testid="can-finish">{{ String(view?.canFinish) }}</span>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
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<TeamsCatalogState | null>({ 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 () => {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import type { AgentTeamsIntegrationSettings, TeamsManagedSetupState } from '@n8n/api-types';
|
||||
import {
|
||||
N8nActionDropdown,
|
||||
N8nButton,
|
||||
N8nCallout,
|
||||
N8nIcon,
|
||||
N8nIconButton,
|
||||
N8nInputLabel,
|
||||
N8nLink,
|
||||
N8nOption,
|
||||
N8nSelect,
|
||||
N8nSpinner,
|
||||
N8nStepper,
|
||||
N8nText,
|
||||
} from '@n8n/design-system';
|
||||
import { useClipboard } from '@n8n/composables/useClipboard';
|
||||
import { getErrorMessage } from '@n8n/utils/errors/get-error-message';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { PermissionsRecord } from '@n8n/permissions';
|
||||
|
|
@ -57,7 +62,10 @@ const emit = defineEmits<{
|
|||
done: [];
|
||||
}>();
|
||||
|
||||
const TEAMS_MANAGE_APPS_URL = 'https://admin.teams.microsoft.com/policies/manage-apps';
|
||||
|
||||
const i18n = useI18n();
|
||||
const clipboard = useClipboard();
|
||||
const rootStore = useRootStore();
|
||||
|
||||
const busy = ref<string | null>(null);
|
||||
|
|
@ -71,6 +79,7 @@ const STEP_OF_ACTION: Record<string, string> = {
|
|||
connect: 'connect',
|
||||
'create-app': 'create-app',
|
||||
'create-bot': 'create-bot',
|
||||
publish: 'install',
|
||||
download: 'install',
|
||||
};
|
||||
const errorStepId = ref('');
|
||||
|
|
@ -84,6 +93,8 @@ const subscriptionId = ref('');
|
|||
const subscriptionsChecked = ref(false);
|
||||
/** Only true while Azure is actually being asked, so the wait cannot outlive it. */
|
||||
const subscriptionsChecking = ref(false);
|
||||
/** Set once the approval request has been put on the clipboard. */
|
||||
const requestCopied = ref(false);
|
||||
const availability = ref<TeamsAvailability>(availabilityFrom());
|
||||
|
||||
const selectedCredential = computed(() =>
|
||||
|
|
@ -101,7 +112,9 @@ const connected = computed(
|
|||
const provisionedApp = computed(() => props.runtime.provisionedApp.value);
|
||||
const provisionedBot = computed(() => props.runtime.provisionedBot.value);
|
||||
const subscriptions = computed(() => props.runtime.subscriptions.value);
|
||||
const catalogState = computed(() => props.runtime.catalogState.value);
|
||||
const botReady = computed(() => provisionedBot.value !== null);
|
||||
const published = computed(() => catalogState.value?.status === 'published');
|
||||
|
||||
/** The agent's own name and blurb, which is how it appears in Teams. */
|
||||
const identityName = computed(
|
||||
|
|
@ -117,14 +130,73 @@ const identityDescription = computed(
|
|||
type InstallRoute = 'choose' | 'downloaded';
|
||||
|
||||
const installRoute = ref<InstallRoute>('choose');
|
||||
/**
|
||||
* Spelled out rather than built from the status, so the keys stay greppable
|
||||
* and the unused-key tooling can see them.
|
||||
*/
|
||||
const CATALOG_STATE_TEXT = {
|
||||
published: 'agents.channels.teams.managed.install.published',
|
||||
submitted: 'agents.channels.teams.managed.install.submitted',
|
||||
rejected: 'agents.channels.teams.managed.install.rejected',
|
||||
unknown: 'agents.channels.teams.managed.install.unknown',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Whether this setup has published yet. The catalogue is read when the step
|
||||
* opens, and before a publish its "not listed" answer is just the truth about
|
||||
* an app nobody published -- not something to report. Downloading the package
|
||||
* does not count: a sideloaded app never enters the catalogue, so its silence
|
||||
* about one says nothing about an upload that happened in Teams.
|
||||
*/
|
||||
const hasPublished = ref(false);
|
||||
|
||||
/**
|
||||
* The two routes are independent, so downloading the package once must not take
|
||||
* publishing away. Only a pending review drops an item: it is the one state
|
||||
* that cannot take another submission.
|
||||
*/
|
||||
const installMenuItems = computed(() => {
|
||||
const items = [
|
||||
{
|
||||
id: 'for-me',
|
||||
testId: 'teams-install-download-package',
|
||||
label: i18n.baseText(
|
||||
installRoute.value === 'downloaded'
|
||||
? 'agents.channels.teams.managed.install.downloadAgain'
|
||||
: 'agents.channels.teams.managed.install.forMe',
|
||||
),
|
||||
description: i18n.baseText('agents.channels.teams.managed.install.forMeHint'),
|
||||
},
|
||||
];
|
||||
if (catalogState.value?.status !== 'submitted') {
|
||||
items.push({
|
||||
id: 'publish',
|
||||
testId: 'teams-publish',
|
||||
label: i18n.baseText('agents.channels.teams.managed.install.publish'),
|
||||
description: i18n.baseText('agents.channels.teams.managed.install.publishHintShort'),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
const onInstallMenu = async (id: string) =>
|
||||
id === 'publish' ? await publishApp() : await downloadPackage();
|
||||
|
||||
/** What is running, for the status row the step shows in place of the menu. */
|
||||
const installProgress = computed(() => {
|
||||
if (busy.value === 'publish')
|
||||
return i18n.baseText('agents.channels.teams.managed.install.publishing');
|
||||
if (busy.value === 'download')
|
||||
return i18n.baseText('agents.channels.teams.managed.install.preparing');
|
||||
return '';
|
||||
});
|
||||
|
||||
/**
|
||||
* Publishing is the only finish n8n can see. A sideload happens in the Teams
|
||||
* client, so that route ends on the step after this one instead.
|
||||
*/
|
||||
const installDone = computed(() => published.value);
|
||||
|
||||
const connectSummary = computed(() =>
|
||||
selectedCredential.value?.organizationName
|
||||
? i18n.baseText('agents.channels.teams.managed.connect.summary', {
|
||||
|
|
@ -153,13 +225,14 @@ const statusOf = computed<Record<string, 'complete' | 'active' | 'locked'>>(() =
|
|||
'create-app': status(appDone, connected.value),
|
||||
'create-bot': status(botReady.value, appDone && connected.value),
|
||||
availability: botReady.value && connected.value ? ('active' as const) : ('locked' as const),
|
||||
install: botReady.value && connected.value ? ('active' as const) : ('locked' as const),
|
||||
install: status(installDone.value, botReady.value && connected.value),
|
||||
// The upload happens in the Teams client, so this step never reports
|
||||
// itself finished -- the user says when it is. Gated on the sign-in like
|
||||
// the steps above: one that has to be redone locks them, and leaving
|
||||
// this one actionable would read as the odd one out.
|
||||
finish:
|
||||
connected.value && (installRoute.value === 'downloaded' || props.channelConnected === true)
|
||||
connected.value &&
|
||||
(installRoute.value === 'downloaded' || published.value || props.channelConnected === true)
|
||||
? ('active' as const)
|
||||
: ('locked' as const),
|
||||
};
|
||||
|
|
@ -279,6 +352,11 @@ watch(
|
|||
// has nothing to do with.
|
||||
installRoute.value = 'choose';
|
||||
persisted.value = false;
|
||||
// The catalogue answer belongs to the tenant that gave it, and
|
||||
// `refreshCatalogState` refuses to let an `unknown` read overwrite a
|
||||
// known one -- so leaving it here does not just flicker, it sticks.
|
||||
props.runtime.catalogState.value = null;
|
||||
hasPublished.value = false;
|
||||
}
|
||||
if (isConnected && !subscriptionsChecked.value) void checkSubscriptions();
|
||||
},
|
||||
|
|
@ -380,16 +458,29 @@ const provisionBot = async () =>
|
|||
/** True once there is a channel to come back to, so leaving loses nothing. */
|
||||
const persisted = ref(false);
|
||||
|
||||
/**
|
||||
* Publishes, and stops there. Adding the app to someone's Teams is the step
|
||||
* after, and the design keeps the two apart: publishing is a decision about
|
||||
* the organisation, adding is one about your own account.
|
||||
*/
|
||||
const publishApp = async () =>
|
||||
await run('publish', async () => {
|
||||
// Only once it went through, and only for the account it went through for:
|
||||
// set before, a refused publish leaves the catalogue's "not listed yet"
|
||||
// sitting beside the error explaining why, and a reply the setup has moved
|
||||
// on from would bind the channel for a tenant nobody published to.
|
||||
if (!(await props.runtime.publishApp(currentSettings.value))) return;
|
||||
hasPublished.value = true;
|
||||
persistChannel();
|
||||
});
|
||||
|
||||
/**
|
||||
* Binds the provisioned credential to the agent, which is what starts it.
|
||||
*
|
||||
* The package route calls this once it has been handed over: the setup is
|
||||
* finished from n8n's side even though the upload happens in Teams. It does
|
||||
* not close the dialog, because the upload is still to come.
|
||||
*
|
||||
* The credential is re-announced first, because saving reads it from the
|
||||
* view's model and nothing else guarantees the view heard about it in this
|
||||
* session.
|
||||
* Every route that puts the app in front of someone calls this -- publishing
|
||||
* and the package alike -- because each finishes the setup on its own, and
|
||||
* which of them a tenant allows is not ours to choose. None of them closes the
|
||||
* dialog: another may still follow.
|
||||
*/
|
||||
function persistChannel() {
|
||||
emit('persist');
|
||||
|
|
@ -412,11 +503,13 @@ watch(
|
|||
// Read by the channel modal when it saves, so the availability chosen here is
|
||||
// what gets stored rather than the defaults.
|
||||
/**
|
||||
* Leaving is allowed once the channel is written, which handing the package
|
||||
* over does. The upload happens in the Teams client, so there is nothing
|
||||
* further for n8n to wait on.
|
||||
* Leaving is allowed once there is something worth saving: a package handed
|
||||
* over, or an app already in the catalogue. The second case is the one a
|
||||
* re-run lands in -- Microsoft keeps the app through a disconnect, so nothing
|
||||
* here writes the channel again, and without Done there is no way to bind the
|
||||
* credential back to the agent at all.
|
||||
*/
|
||||
const canFinish = computed(() => persisted.value);
|
||||
const canFinish = computed(() => persisted.value || published.value);
|
||||
|
||||
defineExpose({ currentSettings, keepOpenAfterConnect: true, canFinish });
|
||||
|
||||
|
|
@ -427,6 +520,23 @@ onBeforeUnmount(() => {
|
|||
unmounted = true;
|
||||
});
|
||||
|
||||
/**
|
||||
* Puts the whole request on the clipboard rather than on the screen: the user
|
||||
* pastes it to whoever can approve it, and has nothing to compose or look up.
|
||||
*
|
||||
* The link is the Manage apps page, which is as deep as Microsoft goes — there
|
||||
* is no URL for one pending app — so the app's name carries the rest. The
|
||||
* administrator finds it under Pending approval.
|
||||
*/
|
||||
async function copyApprovalRequest() {
|
||||
await clipboard.copy(
|
||||
i18n.baseText('agents.channels.teams.managed.install.approvalRequest', {
|
||||
interpolate: { app: provisionedApp.value?.appName ?? '', url: TEAMS_MANAGE_APPS_URL },
|
||||
}),
|
||||
);
|
||||
requestCopied.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the setup to the manual flow, for the walls this one cannot climb: a
|
||||
* tenant that will not let this account register an app, or an account with no
|
||||
|
|
@ -772,30 +882,81 @@ const downloadPackage = async () =>
|
|||
size="medium"
|
||||
data-testid="teams-install-spinner"
|
||||
/>
|
||||
<N8nButton
|
||||
v-else-if="installRoute === 'downloaded'"
|
||||
variant="outline"
|
||||
size="medium"
|
||||
:loading="busy === 'download'"
|
||||
:disabled="busy !== null"
|
||||
data-testid="teams-install-download-package"
|
||||
@click="downloadPackage"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.managed.install.downloadAgain') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
<N8nActionDropdown
|
||||
v-else
|
||||
variant="solid"
|
||||
size="medium"
|
||||
:loading="busy === 'download'"
|
||||
:disabled="busy !== null"
|
||||
data-testid="teams-install-download-package"
|
||||
@click="downloadPackage"
|
||||
:items="installMenuItems"
|
||||
placement="bottom-end"
|
||||
:teleported="false"
|
||||
@select="onInstallMenu"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.managed.install.forMe') }}
|
||||
</N8nButton>
|
||||
<template #activator>
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
size="medium"
|
||||
:disabled="busy !== null"
|
||||
data-testid="teams-install-menu"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.managed.install.menu') }}
|
||||
<N8nIcon icon="chevron-down" size="small" />
|
||||
</N8nButton>
|
||||
</template>
|
||||
</N8nActionDropdown>
|
||||
</template>
|
||||
</AgentChannelTeamsIdentityCard>
|
||||
|
||||
<!--
|
||||
Nothing is said about the catalogue while a call to it is in
|
||||
flight: what it last answered is about to be replaced, and
|
||||
"Microsoft has not listed the app yet" beside a publish that is
|
||||
still running reads as the answer to that publish.
|
||||
-->
|
||||
<template
|
||||
v-if="
|
||||
!installProgress &&
|
||||
catalogState &&
|
||||
(catalogState.status !== 'unknown' || hasPublished)
|
||||
"
|
||||
>
|
||||
<!--
|
||||
A review is a wall, not a status line: nobody can add the app
|
||||
until someone acts. The state the user can do nothing about
|
||||
stays quiet text.
|
||||
-->
|
||||
<N8nCallout
|
||||
v-if="catalogState.status === 'submitted'"
|
||||
theme="warning"
|
||||
data-testid="teams-catalog-state"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.managed.install.submitted') }}
|
||||
</N8nCallout>
|
||||
<N8nText v-else size="small" color="text-light" data-testid="teams-catalog-state">
|
||||
{{ i18n.baseText(CATALOG_STATE_TEXT[catalogState.status]) }}
|
||||
</N8nText>
|
||||
<!-- Said here because the one-off fix is an administrator's to make. -->
|
||||
<N8nText
|
||||
v-if="catalogState.status === 'submitted'"
|
||||
size="small"
|
||||
color="text-light"
|
||||
data-testid="teams-catalog-submitted-why"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.managed.install.submittedWhy') }}
|
||||
</N8nText>
|
||||
<N8nButton
|
||||
v-if="catalogState.status === 'submitted'"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
data-testid="teams-copy-approval-request"
|
||||
@click="copyApprovalRequest"
|
||||
>
|
||||
{{
|
||||
i18n.baseText(
|
||||
requestCopied
|
||||
? 'agents.channels.teams.managed.install.approvalRequestCopied'
|
||||
: 'agents.channels.teams.managed.install.copyApprovalRequest',
|
||||
)
|
||||
}}
|
||||
</N8nButton>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
|
|
@ -807,12 +968,45 @@ const downloadPackage = async () =>
|
|||
v-else-if="step.id === 'finish' && statusOf[step.id] === 'active'"
|
||||
:class="$style.stepContent"
|
||||
>
|
||||
<N8nText size="small" color="text-light" data-testid="teams-finish-instructions">
|
||||
<!--
|
||||
What is left to do depends on the route taken. A published app is
|
||||
in nobody's Teams until Microsoft lists it, so the package stays
|
||||
on offer as the way to have it sooner.
|
||||
-->
|
||||
<template v-if="installRoute === 'downloaded'">
|
||||
<N8nText size="small" color="text-light" data-testid="teams-finish-instructions">
|
||||
{{ i18n.baseText('agents.channels.teams.managed.install.downloaded') }}
|
||||
</N8nText>
|
||||
<N8nText size="small" color="text-light" data-testid="teams-finish-upload-blocked">
|
||||
{{ i18n.baseText('agents.channels.teams.managed.install.noUploadOption') }}
|
||||
</N8nText>
|
||||
</template>
|
||||
<template v-else-if="published">
|
||||
<N8nText size="small" color="text-light" data-testid="teams-finish-published">
|
||||
{{ i18n.baseText('agents.channels.teams.managed.finish.published') }}
|
||||
</N8nText>
|
||||
<!--
|
||||
One sentence with the action inside it. A button here carries
|
||||
its own padding, which pushes the line out of alignment with
|
||||
the paragraph above and reads as a second block.
|
||||
-->
|
||||
<N8nText size="small" color="text-light" data-testid="teams-finish-sooner">
|
||||
{{ i18n.baseText('agents.channels.teams.managed.finish.soonerBefore') }}
|
||||
<N8nLink
|
||||
theme="text"
|
||||
size="small"
|
||||
underline
|
||||
data-testid="teams-finish-download-package"
|
||||
@click="downloadPackage"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.managed.finish.soonerLink') }}
|
||||
</N8nLink>
|
||||
{{ i18n.baseText('agents.channels.teams.managed.finish.soonerAfter') }}
|
||||
</N8nText>
|
||||
</template>
|
||||
<N8nText v-else size="small" color="text-light" data-testid="teams-finish-instructions">
|
||||
{{ i18n.baseText('agents.channels.teams.managed.install.downloaded') }}
|
||||
</N8nText>
|
||||
<N8nText size="small" color="text-light" data-testid="teams-finish-upload-blocked">
|
||||
{{ i18n.baseText('agents.channels.teams.managed.install.noUploadOption') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ const withBot = () =>
|
|||
botId: CLIENT_ID,
|
||||
deployToAzureUrl: DEPLOY_URL,
|
||||
credentialClaimedBy: null,
|
||||
provisionedByN8n: true,
|
||||
defaultDisplayName: DEFAULT_NAME,
|
||||
defaultDescription: DEFAULT_DESCRIPTION,
|
||||
});
|
||||
|
|
@ -115,6 +116,7 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
botId: null,
|
||||
deployToAzureUrl: DEPLOY_URL,
|
||||
credentialClaimedBy: null,
|
||||
provisionedByN8n: true,
|
||||
defaultDisplayName: DEFAULT_NAME,
|
||||
defaultDescription: DEFAULT_DESCRIPTION,
|
||||
});
|
||||
|
|
@ -272,6 +274,7 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
botId: null,
|
||||
deployToAzureUrl: null,
|
||||
credentialClaimedBy: null,
|
||||
provisionedByN8n: true,
|
||||
defaultDisplayName: DEFAULT_NAME,
|
||||
defaultDescription: DEFAULT_DESCRIPTION,
|
||||
});
|
||||
|
|
@ -1047,7 +1050,7 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
const { getByTestId } = renderHost({ props: { viewProps: settingsProps() } });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
|
||||
expect(getByTestId('save-label')).toHaveTextContent('');
|
||||
expect(getByTestId('save-label')).toBeEmptyDOMElement();
|
||||
await fireEvent.click(getByTestId('save'));
|
||||
|
||||
await waitFor(() => expect(getByTestId('saved-settings')).not.toBeEmptyDOMElement());
|
||||
|
|
@ -1090,7 +1093,7 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
const { getByTestId, rerender } = renderHost({ props: { viewProps: settingsProps() } });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
|
||||
expect(getByTestId('save-label')).toHaveTextContent('');
|
||||
expect(getByTestId('save-label')).toBeEmptyDOMElement();
|
||||
await rerender({ viewProps: settingsProps({ modelValue: 'cred-2' }) });
|
||||
|
||||
await waitFor(() =>
|
||||
|
|
@ -1143,7 +1146,7 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
|
||||
expect(getByTestId('save-label')).toHaveTextContent('');
|
||||
expect(getByTestId('save-label')).toBeEmptyDOMElement();
|
||||
await openAvailability(getByTestId);
|
||||
await fireEvent.click(getByTestId('teams-read-channels'));
|
||||
|
||||
|
|
@ -1221,7 +1224,7 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
await fireEvent.click(getByTestId('save'));
|
||||
|
||||
await waitFor(() => expect(getByTestId('saved-settings')).not.toBeEmptyDOMElement());
|
||||
expect(getByTestId('save-label')).toHaveTextContent('');
|
||||
expect(getByTestId('save-label')).toBeEmptyDOMElement();
|
||||
expect(fetchTeamsAppPackage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1277,6 +1280,7 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
// The server withholds the deployment for the same reason.
|
||||
deployToAzureUrl: null,
|
||||
credentialClaimedBy: 'Sales Bot',
|
||||
provisionedByN8n: true,
|
||||
defaultDisplayName: DEFAULT_NAME,
|
||||
defaultDescription: DEFAULT_DESCRIPTION,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import type { PermissionsRecord } from '@n8n/permissions';
|
|||
import AgentIntegrationCredentialConnection from '../../components/AgentIntegrationCredentialConnection.vue';
|
||||
import type { AgentCredentialOption } from '../../components/AgentCredentialSelect.vue';
|
||||
import AgentChannelTeamsAvailability from './AgentChannelTeamsAvailability.vue';
|
||||
import AgentChannelTeamsManagedApp from './AgentChannelTeamsManagedApp.vue';
|
||||
import type { TeamsChannelRuntime } from './useTeamsChannelRuntime';
|
||||
import { availabilityFrom, TEAMS_PACKAGE_FILENAME, type TeamsAvailability } from './constants';
|
||||
import AgentChannelTeamsIdentityCard from './AgentChannelTeamsIdentityCard.vue';
|
||||
import { useAgentTelemetry } from '../../composables/useAgentTelemetry';
|
||||
|
|
@ -45,6 +47,14 @@ const props = withDefaults(
|
|||
savedSettings?: AgentTeamsIntegrationSettings;
|
||||
personalisation?: AgentJsonConfig['personalisation'] | null;
|
||||
ensureAgentPersisted?: () => Promise<void>;
|
||||
/** Saves without closing the modal, so the card can act on saved settings. */
|
||||
persistChannel?: () => Promise<boolean>;
|
||||
/**
|
||||
* Present when the recommended setup is available here. The settings then
|
||||
* offer publishing to the organisation, in place of the package the
|
||||
* manual route hands over.
|
||||
*/
|
||||
managedRuntime?: TeamsChannelRuntime;
|
||||
}>(),
|
||||
{
|
||||
credentialsLoading: false,
|
||||
|
|
@ -57,6 +67,7 @@ const props = withDefaults(
|
|||
savedSettings: undefined,
|
||||
personalisation: null,
|
||||
ensureAgentPersisted: undefined,
|
||||
persistChannel: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -78,6 +89,13 @@ const ENTRA_APP_REGISTRATION_URL =
|
|||
'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/CreateApplicationBlade';
|
||||
|
||||
const setupState = ref<TeamsAgentSetupState | null>(null);
|
||||
/**
|
||||
* Who built the channel's app, held apart from `setupState` because that is
|
||||
* cleared whenever the read fails. Reading a failure as "built by hand" makes
|
||||
* the save hand over a package instead of publishing, so the change never
|
||||
* reaches the organisation's catalogue. `null` means nobody has answered yet.
|
||||
*/
|
||||
const provisionedByN8n = ref<boolean | null>(null);
|
||||
// Only a picked credential makes the setup state worth retrying.
|
||||
const setupLoadFailed = ref(false);
|
||||
const showEndpoint = ref(false);
|
||||
|
|
@ -85,6 +103,23 @@ const showEndpoint = ref(false);
|
|||
// A hidden read permission is never kept on: nothing here could turn it off.
|
||||
const availability = ref<TeamsAvailability>(availabilityFrom(props.savedSettings));
|
||||
|
||||
/**
|
||||
* Whether this channel's app is one n8n made, which is what the managed card
|
||||
* acts on: it publishes that app, and it builds that app's package. Provenance
|
||||
* only -- it says nothing about whether anybody published, so nothing here may
|
||||
* publish on its own.
|
||||
*/
|
||||
const offerManagedApp = computed(() => {
|
||||
const managed = props.managedRuntime?.managedSetup.value;
|
||||
return Boolean(
|
||||
// Provenance first: a channel wired up by hand keeps the manual block,
|
||||
// even in a project that also happens to carry a Microsoft sign-in.
|
||||
provisionedByN8n.value === true &&
|
||||
managed?.managedSetupAvailable &&
|
||||
managed.managerCredentials.some(({ connected }) => connected),
|
||||
);
|
||||
});
|
||||
|
||||
// The credential the saved channel runs on, so a swap counts as an app change.
|
||||
const savedCredentialId = credentialId.value;
|
||||
|
||||
|
|
@ -343,7 +378,10 @@ async function loadSetupState() {
|
|||
props.agentId,
|
||||
credentialId.value || undefined,
|
||||
);
|
||||
if (request === latestSetupState) setupState.value = state;
|
||||
if (request === latestSetupState) {
|
||||
setupState.value = state;
|
||||
provisionedByN8n.value = state.provisionedByN8n;
|
||||
}
|
||||
} catch {
|
||||
if (request !== latestSetupState) return;
|
||||
setupState.value = null;
|
||||
|
|
@ -437,19 +475,33 @@ async function downloadSaved() {
|
|||
if (await downloadPackage(props.savedSettings, savedCredentialId)) showDownloaded();
|
||||
}
|
||||
|
||||
const saveLabel = computed(() =>
|
||||
manifestChanged.value
|
||||
? i18n.baseText('agents.channels.teams.settings.saveAndDownload')
|
||||
: undefined,
|
||||
);
|
||||
/**
|
||||
* A manifest change on a hand-made channel reaches Teams one way, as a new
|
||||
* package, so the save hands it over: the modal closes on the save, and a
|
||||
* download costs the user nothing.
|
||||
*
|
||||
* The managed route keeps a plain Save. Publishing reaches everyone in the
|
||||
* organisation, or asks an administrator to approve it, and a channel somebody
|
||||
* installed only for themselves has asked for neither. The card below offers
|
||||
* both routes by name, and the notice above it says to take one.
|
||||
*/
|
||||
const saveLabel = computed(() => {
|
||||
if (!manifestChanged.value || offerManagedApp.value) return undefined;
|
||||
return i18n.baseText('agents.channels.teams.settings.saveAndDownload');
|
||||
});
|
||||
|
||||
// Captured before the save, because the saved settings then catch up with the form.
|
||||
let pendingPackage: { settings: AgentTeamsIntegrationSettings; credentialId: string } | null = null;
|
||||
|
||||
async function beforeSave() {
|
||||
pendingPackage = manifestChanged.value
|
||||
? { settings: currentSettings.value, credentialId: credentialId.value }
|
||||
: null;
|
||||
// A save that carries a manifest change picks its route from the provenance,
|
||||
// so an unanswered read is worth one more attempt before it decides.
|
||||
if (manifestChanged.value && provisionedByN8n.value === null) await loadSetupState();
|
||||
|
||||
pendingPackage =
|
||||
manifestChanged.value && !offerManagedApp.value
|
||||
? { settings: currentSettings.value, credentialId: credentialId.value }
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -763,52 +815,77 @@ defineExpose({
|
|||
:class="manifestChanged ? undefined : $style.hint"
|
||||
data-testid="teams-update-notice"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.settings.updateNotice') }}
|
||||
{{
|
||||
i18n.baseText(
|
||||
offerManagedApp
|
||||
? manifestChanged
|
||||
? 'agents.channels.teams.settings.updateNoticeManaged'
|
||||
: 'agents.channels.teams.settings.updateNoticeManagedUnchanged'
|
||||
: 'agents.channels.teams.settings.updateNotice',
|
||||
)
|
||||
}}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<div :class="$style.field" data-testid="teams-identity-field">
|
||||
<N8nText size="small" bold>
|
||||
{{ i18n.baseText('agents.channels.teams.settings.identityLabel') }}
|
||||
</N8nText>
|
||||
<AgentChannelTeamsIdentityCard
|
||||
:name="effectiveDisplayName"
|
||||
:description="effectiveDescription"
|
||||
:personalisation="personalisation"
|
||||
:tooltip="
|
||||
savedIdentityOverride
|
||||
? ''
|
||||
: i18n.baseText('agents.channels.teams.setup.install.identityTooltip')
|
||||
"
|
||||
:ready="canDownloadPackage"
|
||||
:loading="downloading"
|
||||
@download="downloadSaved"
|
||||
/>
|
||||
<N8nText :class="$style.hint" size="small" data-testid="teams-upload-instructions">
|
||||
{{ i18n.baseText('agents.channels.teams.settings.uploadHint') }}
|
||||
</N8nText>
|
||||
<N8nText
|
||||
v-if="downloadError"
|
||||
<!-- The managed route publishes to the organisation; the manual one hands
|
||||
over a package. Only one of them is this channel's, so only one is shown. -->
|
||||
<AgentChannelTeamsManagedApp
|
||||
v-if="offerManagedApp && managedRuntime"
|
||||
:runtime="managedRuntime"
|
||||
:name="effectiveDisplayName"
|
||||
:description="effectiveDescription"
|
||||
:credential-id="credentialId"
|
||||
:project-id="projectId"
|
||||
:agent-id="agentId"
|
||||
:settings="savedSettings"
|
||||
:pending-settings="currentSettings"
|
||||
:unsaved-changes="manifestChanged"
|
||||
:persist-channel="persistChannel"
|
||||
/>
|
||||
<template v-else>
|
||||
<div :class="$style.field" data-testid="teams-identity-field">
|
||||
<N8nText size="small" bold>
|
||||
{{ i18n.baseText('agents.channels.teams.settings.identityLabel') }}
|
||||
</N8nText>
|
||||
<AgentChannelTeamsIdentityCard
|
||||
:name="effectiveDisplayName"
|
||||
:description="effectiveDescription"
|
||||
:personalisation="personalisation"
|
||||
:tooltip="
|
||||
savedIdentityOverride
|
||||
? ''
|
||||
: i18n.baseText('agents.channels.teams.setup.install.identityTooltip')
|
||||
"
|
||||
:ready="canDownloadPackage"
|
||||
:loading="downloading"
|
||||
@download="downloadSaved"
|
||||
/>
|
||||
<N8nText :class="$style.hint" size="small" data-testid="teams-upload-instructions">
|
||||
{{ i18n.baseText('agents.channels.teams.settings.uploadHint') }}
|
||||
</N8nText>
|
||||
<N8nText
|
||||
v-if="downloadError"
|
||||
size="small"
|
||||
:class="$style.error"
|
||||
data-testid="teams-download-error"
|
||||
>
|
||||
{{ downloadError }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nLink
|
||||
:href="AZURE_BOTS_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="small"
|
||||
:class="$style.error"
|
||||
data-testid="teams-download-error"
|
||||
:class="$style.azureLink"
|
||||
data-testid="teams-azure-bot-link"
|
||||
>
|
||||
{{ downloadError }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nLink
|
||||
:href="AZURE_BOTS_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="small"
|
||||
:class="$style.azureLink"
|
||||
data-testid="teams-azure-bot-link"
|
||||
>
|
||||
<span :class="$style.linkContent">
|
||||
{{ i18n.baseText('agents.channels.teams.settings.findBot') }}
|
||||
<N8nIcon icon="external-link" size="xsmall" />
|
||||
</span>
|
||||
</N8nLink>
|
||||
<span :class="$style.linkContent">
|
||||
{{ i18n.baseText('agents.channels.teams.settings.findBot') }}
|
||||
<N8nIcon icon="external-link" size="xsmall" />
|
||||
</span>
|
||||
</N8nLink>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ vi.mock('./api', () => ({
|
|||
botId: null,
|
||||
deployToAzureUrl: null,
|
||||
credentialClaimedBy: null,
|
||||
provisionedByN8n: true,
|
||||
defaultDisplayName: 'Agent',
|
||||
defaultDescription: 'An agent',
|
||||
}),
|
||||
|
|
@ -51,10 +52,14 @@ const buildRuntime = (
|
|||
botSetupState: ref(null),
|
||||
provisionedBot: ref(null),
|
||||
subscriptions: ref([]),
|
||||
catalogState: ref(null),
|
||||
connectedCredentialId: ref(''),
|
||||
provisionApp: vi.fn(),
|
||||
loadSubscriptions: vi.fn(),
|
||||
provisionBot: vi.fn(),
|
||||
reportManualBotRoute: vi.fn(),
|
||||
publishApp: vi.fn(),
|
||||
refreshCatalogState: vi.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@ import {
|
|||
fetchTeamsAppPackage,
|
||||
getTeamsAzureSubscriptions,
|
||||
getTeamsManagedSetup,
|
||||
getTeamsCatalogState,
|
||||
getTeamsSetupState,
|
||||
provisionTeamsApp,
|
||||
provisionTeamsBot,
|
||||
publishTeamsApp,
|
||||
} from './api';
|
||||
|
||||
vi.mock('@n8n/rest-api-client', async (importOriginal) => ({
|
||||
|
|
@ -105,6 +107,43 @@ describe('teams channel api', () => {
|
|||
expect(sent()).toEqual([context, 'POST', `${BASE}/provision-bot`, payload]);
|
||||
});
|
||||
|
||||
it('publishes the package to the organisation catalogue', async () => {
|
||||
const payload = {
|
||||
managerCredentialId: 'manager-1',
|
||||
credentialId: 'bot-cred-1',
|
||||
settings: { teamChannels: true },
|
||||
};
|
||||
|
||||
await publishTeamsApp(context, 'project-1', 'agent-1', payload);
|
||||
|
||||
expect(sent()).toEqual([context, 'POST', `${BASE}/publish`, payload]);
|
||||
});
|
||||
|
||||
describe('getTeamsCatalogState', () => {
|
||||
it('reads where the app stands, with the sign-in to read it with', async () => {
|
||||
await getTeamsCatalogState(context, 'project-1', 'agent-1', 'manager-1');
|
||||
|
||||
expect(sent()).toEqual([
|
||||
context,
|
||||
'GET',
|
||||
`${BASE}/catalog`,
|
||||
{ managerCredentialId: 'manager-1' },
|
||||
]);
|
||||
});
|
||||
|
||||
/** The channel's own credential carries what the last publish recorded. */
|
||||
it('names the channel credential when there is one', async () => {
|
||||
await getTeamsCatalogState(context, 'project-1', 'agent-1', 'manager-1', 'bot-cred-1');
|
||||
|
||||
expect(sent()).toEqual([
|
||||
context,
|
||||
'GET',
|
||||
`${BASE}/catalog`,
|
||||
{ managerCredentialId: 'manager-1', credentialId: 'bot-cred-1' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchTeamsAppPackage', () => {
|
||||
/**
|
||||
* Fetched rather than linked to: the session cookie is bound to a
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type {
|
|||
TeamsAgentSetupState,
|
||||
TeamsCredentialCheck,
|
||||
TeamsAzureSubscription,
|
||||
TeamsCatalogState,
|
||||
TeamsManagedSetupState,
|
||||
TeamsProvisionedAppSummary,
|
||||
TeamsProvisionedBotSummary,
|
||||
|
|
@ -142,3 +143,33 @@ export const provisionTeamsBot = async (
|
|||
`${integrationPath(projectId, agentId)}/provision-bot`,
|
||||
payload,
|
||||
);
|
||||
|
||||
export const publishTeamsApp = async (
|
||||
context: IRestApiContext,
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
payload: {
|
||||
managerCredentialId: string;
|
||||
credentialId: string;
|
||||
settings?: AgentTeamsIntegrationSettings;
|
||||
},
|
||||
): Promise<TeamsCatalogState> =>
|
||||
await makeRestApiRequest(
|
||||
context,
|
||||
'POST',
|
||||
`${integrationPath(projectId, agentId)}/publish`,
|
||||
payload,
|
||||
);
|
||||
|
||||
export const getTeamsCatalogState = async (
|
||||
context: IRestApiContext,
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
managerCredentialId: string,
|
||||
/** The channel's own credential, which carries what the last publish made. */
|
||||
credentialId?: string,
|
||||
): Promise<TeamsCatalogState> =>
|
||||
await makeRestApiRequest(context, 'GET', `${integrationPath(projectId, agentId)}/catalog`, {
|
||||
managerCredentialId,
|
||||
...(credentialId ? { credentialId } : {}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { flushPromises } from '@vue/test-utils';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { computed, ref } from 'vue';
|
||||
|
|
@ -6,10 +7,12 @@ import { computed, ref } from 'vue';
|
|||
import {
|
||||
createTeamsManagerCredential,
|
||||
getTeamsAzureSubscriptions,
|
||||
getTeamsCatalogState,
|
||||
getTeamsManagedSetup,
|
||||
getTeamsSetupState,
|
||||
provisionTeamsApp,
|
||||
reportTeamsManualBotRoute,
|
||||
publishTeamsApp,
|
||||
} from './api';
|
||||
import { useTeamsChannelRuntime } from './useTeamsChannelRuntime';
|
||||
|
||||
|
|
@ -18,6 +21,8 @@ vi.mock('./api', () => ({
|
|||
createTeamsManagerCredential: vi.fn(),
|
||||
getTeamsSetupState: vi.fn(),
|
||||
getTeamsAzureSubscriptions: vi.fn(),
|
||||
getTeamsCatalogState: vi.fn(),
|
||||
publishTeamsApp: vi.fn(),
|
||||
provisionTeamsApp: vi.fn(),
|
||||
provisionTeamsBot: vi.fn(),
|
||||
reportTeamsManualBotRoute: vi.fn(),
|
||||
|
|
@ -251,6 +256,24 @@ describe('useTeamsChannelRuntime', () => {
|
|||
await expect(runtime.loadSubscriptions()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* The upload is the slowest call here, so the likeliest to land late --
|
||||
* and its caller binds the channel on a success, which must not happen
|
||||
* for a tenant nobody published to.
|
||||
*/
|
||||
it('says a publish that landed for another sign-in was not applied', async () => {
|
||||
const runtime = buildRuntime();
|
||||
runtime.managerCredentialId.value = 'cred-1';
|
||||
runtime.provisionedApp.value = { credentialId: 'bot-cred-1' } as never;
|
||||
vi.mocked(publishTeamsApp).mockImplementation(async () => {
|
||||
runtime.managerCredentialId.value = 'cred-2';
|
||||
return { status: 'published', teamsAppId: 'teams-app-1' };
|
||||
});
|
||||
|
||||
await expect(runtime.publishApp()).resolves.toBe(false);
|
||||
expect(runtime.catalogState.value).toBeNull();
|
||||
});
|
||||
|
||||
it('says a reply for the sign-in still selected was applied', async () => {
|
||||
const runtime = buildRuntime();
|
||||
runtime.managerCredentialId.value = 'cred-1';
|
||||
|
|
@ -306,4 +329,104 @@ describe('useTeamsChannelRuntime', () => {
|
|||
await expect(buildRuntime().reportManualBotRoute()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Publishing can take a day to take effect, so reopening the setup later is
|
||||
* the ordinary path. Until this the step only knew what it had done itself,
|
||||
* and an app published or removed since read as whatever it last saw.
|
||||
*/
|
||||
it('reads the catalogue back when there is a sign-in to read it with', async () => {
|
||||
vi.mocked(getTeamsManagedSetup).mockResolvedValue({
|
||||
managedSetupAvailable: true,
|
||||
managerCredentials: [
|
||||
{
|
||||
id: 'cred-1',
|
||||
name: 'Microsoft organization',
|
||||
connected: true,
|
||||
reconnectRequired: false,
|
||||
organizationName: 'Acme Corp',
|
||||
tenantId: 'tenant-1',
|
||||
},
|
||||
],
|
||||
adminConsentUrl: null,
|
||||
});
|
||||
vi.mocked(getTeamsCatalogState).mockResolvedValue({
|
||||
status: 'published',
|
||||
teamsAppId: 'teams-app-1',
|
||||
});
|
||||
const runtime = buildRuntime();
|
||||
|
||||
await runtime.load();
|
||||
await flushPromises();
|
||||
|
||||
expect(getTeamsCatalogState).toHaveBeenCalled();
|
||||
expect(runtime.catalogState.value).toEqual({ status: 'published', teamsAppId: 'teams-app-1' });
|
||||
});
|
||||
|
||||
/**
|
||||
* For minutes after a publish the catalogue answers "unknown" to everyone,
|
||||
* including about an app it has just been given. Taking that as the truth
|
||||
* sent the step back to offering a publish that had already happened, which
|
||||
* Microsoft then refused as a duplicate.
|
||||
*/
|
||||
it('keeps a state it watched Microsoft report over a catalogue that cannot answer', async () => {
|
||||
vi.mocked(getTeamsManagedSetup).mockResolvedValue({
|
||||
managedSetupAvailable: true,
|
||||
managerCredentials: [
|
||||
{
|
||||
id: 'cred-1',
|
||||
name: 'Microsoft organization',
|
||||
connected: true,
|
||||
reconnectRequired: false,
|
||||
organizationName: 'Acme Corp',
|
||||
tenantId: 'tenant-1',
|
||||
},
|
||||
],
|
||||
adminConsentUrl: null,
|
||||
});
|
||||
vi.mocked(getTeamsCatalogState).mockResolvedValue({ status: 'unknown', teamsAppId: null });
|
||||
const runtime = buildRuntime();
|
||||
// Through load, or there is no sign-in to read the catalogue with and
|
||||
// the read never happens.
|
||||
await runtime.load();
|
||||
runtime.catalogState.value = { status: 'published', teamsAppId: 'teams-app-1' };
|
||||
|
||||
await runtime.refreshCatalogState();
|
||||
|
||||
expect(runtime.catalogState.value).toEqual({ status: 'published', teamsAppId: 'teams-app-1' });
|
||||
});
|
||||
|
||||
it('takes an answer the catalogue can give', async () => {
|
||||
vi.mocked(getTeamsManagedSetup).mockResolvedValue({
|
||||
managedSetupAvailable: true,
|
||||
managerCredentials: [
|
||||
{
|
||||
id: 'cred-1',
|
||||
name: 'Microsoft organization',
|
||||
connected: true,
|
||||
reconnectRequired: false,
|
||||
organizationName: 'Acme Corp',
|
||||
tenantId: 'tenant-1',
|
||||
},
|
||||
],
|
||||
adminConsentUrl: null,
|
||||
});
|
||||
vi.mocked(getTeamsCatalogState).mockResolvedValue({ status: 'rejected', teamsAppId: 'a-1' });
|
||||
const runtime = buildRuntime();
|
||||
await runtime.load();
|
||||
runtime.catalogState.value = { status: 'published', teamsAppId: 'teams-app-1' };
|
||||
|
||||
await runtime.refreshCatalogState();
|
||||
|
||||
expect(runtime.catalogState.value).toEqual({ status: 'rejected', teamsAppId: 'a-1' });
|
||||
});
|
||||
|
||||
it('does not ask the catalogue without a sign-in to ask with', async () => {
|
||||
const runtime = buildRuntime();
|
||||
|
||||
await runtime.load();
|
||||
await flushPromises();
|
||||
|
||||
expect(getTeamsCatalogState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type {
|
||||
AgentTeamsIntegrationSettings,
|
||||
TeamsAzureSubscription,
|
||||
TeamsCatalogState,
|
||||
TeamsAgentSetupState,
|
||||
TeamsManagedSetupState,
|
||||
TeamsProvisionedAppSummary,
|
||||
|
|
@ -17,11 +19,13 @@ import type { AgentChannelRuntime, AgentChannelRuntimeContext } from '../types';
|
|||
import {
|
||||
createTeamsManagerCredential,
|
||||
getTeamsAzureSubscriptions,
|
||||
getTeamsCatalogState,
|
||||
getTeamsManagedSetup,
|
||||
getTeamsSetupState,
|
||||
provisionTeamsApp,
|
||||
provisionTeamsBot,
|
||||
reportTeamsManualBotRoute,
|
||||
publishTeamsApp,
|
||||
} from './api';
|
||||
|
||||
const TEAMS_MANAGER_CREDENTIAL_TYPE = 'microsoftTeamsManagerOAuth2Api';
|
||||
|
|
@ -41,6 +45,8 @@ export interface TeamsChannelRuntime extends AgentChannelRuntime {
|
|||
botSetupState: Ref<TeamsAgentSetupState | null>;
|
||||
provisionedBot: Ref<TeamsProvisionedBotSummary | null>;
|
||||
subscriptions: Ref<TeamsAzureSubscription[]>;
|
||||
catalogState: Ref<TeamsCatalogState | null>;
|
||||
connectedCredentialId: Ref<string>;
|
||||
/**
|
||||
* Each reports whether its reply was applied. A reply for an agent or a
|
||||
* sign-in the setup has moved on from is dropped, and a caller that read a
|
||||
|
|
@ -52,6 +58,8 @@ export interface TeamsChannelRuntime extends AgentChannelRuntime {
|
|||
provisionBot: (subscriptionId: string) => Promise<boolean>;
|
||||
/** Says the setup left the recommended flow to make the bot by hand. */
|
||||
reportManualBotRoute: () => Promise<void>;
|
||||
publishApp: (settings?: AgentTeamsIntegrationSettings) => Promise<boolean>;
|
||||
refreshCatalogState: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function isTeamsChannelRuntime(
|
||||
|
|
@ -85,6 +93,7 @@ export function useTeamsChannelRuntime(context: AgentChannelRuntimeContext): Tea
|
|||
const botSetupState = ref<TeamsAgentSetupState | null>(null);
|
||||
const provisionedBot = ref<TeamsProvisionedBotSummary | null>(null);
|
||||
const subscriptions = ref<TeamsAzureSubscription[]>([]);
|
||||
const catalogState = ref<TeamsCatalogState | null>(null);
|
||||
|
||||
/** Every provisioning call needs a finished sign-in to act with. */
|
||||
function requireManagerCredential(): string {
|
||||
|
|
@ -93,8 +102,24 @@ export function useTeamsChannelRuntime(context: AgentChannelRuntimeContext): Tea
|
|||
return managerCredentialId.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel's own credential, handed over by the settings view. A setup
|
||||
* reopened after it connected never ran the step that would have recorded
|
||||
* one, but the channel is bound to the very credential that step wrote.
|
||||
*/
|
||||
const connectedCredentialId = ref('');
|
||||
|
||||
/**
|
||||
* The settings view's credential wins: it is the one the save binds to the
|
||||
* agent, so publishing the one this session happened to register would put a
|
||||
* manifest in the catalogue naming a bot the channel does not use.
|
||||
*/
|
||||
function channelCredentialId(): string {
|
||||
return connectedCredentialId.value || (provisionedApp.value?.credentialId ?? '');
|
||||
}
|
||||
|
||||
function requireProvisionedCredential(): string {
|
||||
const credentialId = provisionedApp.value?.credentialId;
|
||||
const credentialId = channelCredentialId();
|
||||
if (!credentialId)
|
||||
throw new Error(i18n.baseText('agents.channels.teams.managed.errors.createAppFirst'));
|
||||
return credentialId;
|
||||
|
|
@ -138,6 +163,12 @@ export function useTeamsChannelRuntime(context: AgentChannelRuntimeContext): Tea
|
|||
// picker when the project already has one.
|
||||
const connected = managedSetup.value.managerCredentials.find((item) => item.connected);
|
||||
if (!managerCredentialId.value && connected) managerCredentialId.value = connected.id;
|
||||
|
||||
// What the catalogue says now, rather than what it said when this setup
|
||||
// was last open. Publishing can take a day to take effect, so coming back
|
||||
// later is the ordinary path, and until this the step only ever knew what
|
||||
// it had done itself. Not awaited: the stepper reads without it.
|
||||
if (managerCredentialId.value) void refreshCatalogState().catch(() => {});
|
||||
}
|
||||
|
||||
async function connectManagerCredential(credentialId?: string): Promise<boolean> {
|
||||
|
|
@ -265,6 +296,56 @@ export function useTeamsChannelRuntime(context: AgentChannelRuntimeContext): Tea
|
|||
return true;
|
||||
}
|
||||
|
||||
async function publishApp(settings?: AgentTeamsIntegrationSettings): Promise<boolean> {
|
||||
// The slowest call here -- it uploads the package -- so the most likely
|
||||
// to land after the account or the agent moved on.
|
||||
const requestedFor = contextKey();
|
||||
const state = await publishTeamsApp(
|
||||
rootStore.restApiContext,
|
||||
context.projectId.value,
|
||||
context.agentId.value,
|
||||
{
|
||||
managerCredentialId: requireManagerCredential(),
|
||||
credentialId: requireProvisionedCredential(),
|
||||
settings,
|
||||
},
|
||||
);
|
||||
if (requestedFor !== contextKey()) return false;
|
||||
catalogState.value = state;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function refreshCatalogState() {
|
||||
const askedWith = managerCredentialId.value;
|
||||
if (!askedWith) return;
|
||||
// `load()` fires this without awaiting it, and `load()` itself runs from
|
||||
// three places -- so several reads can be in flight at once, each able to
|
||||
// answer about a different tenant.
|
||||
const requestedFor = contextKey();
|
||||
const state = await getTeamsCatalogState(
|
||||
rootStore.restApiContext,
|
||||
context.projectId.value,
|
||||
context.agentId.value,
|
||||
askedWith,
|
||||
channelCredentialId(),
|
||||
);
|
||||
if (requestedFor !== contextKey()) return;
|
||||
|
||||
// `unknown` means the catalogue could not answer, not that the app is
|
||||
// gone -- and for minutes after a publish that is the only answer it
|
||||
// gives. Letting it overwrite a state we watched Microsoft report sends
|
||||
// the step back to offering a publish that has already happened, which
|
||||
// Microsoft then refuses as a duplicate.
|
||||
if (
|
||||
state.status === 'unknown' &&
|
||||
catalogState.value &&
|
||||
catalogState.value.status !== 'unknown'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
catalogState.value = state;
|
||||
}
|
||||
|
||||
function editManagerCredential(credentialId: string) {
|
||||
uiStore.openExistingCredential(credentialId, {
|
||||
hideAskAssistant: true,
|
||||
|
|
@ -299,9 +380,13 @@ export function useTeamsChannelRuntime(context: AgentChannelRuntimeContext): Tea
|
|||
botSetupState,
|
||||
provisionedBot,
|
||||
subscriptions,
|
||||
catalogState,
|
||||
connectedCredentialId,
|
||||
provisionApp,
|
||||
loadSubscriptions,
|
||||
provisionBot,
|
||||
reportManualBotRoute,
|
||||
publishApp,
|
||||
refreshCatalogState,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,12 @@ export interface AgentChannelViewProps {
|
|||
agentId: string;
|
||||
personalisation?: AgentJsonConfig['personalisation'] | null;
|
||||
ensureAgentPersisted?: () => Promise<void>;
|
||||
/**
|
||||
* Saves the channel without closing the modal, and reports whether it
|
||||
* landed. For a view whose own buttons act on what n8n has saved: they wait
|
||||
* for this rather than for the footer, which closes the dialog under them.
|
||||
*/
|
||||
persistChannel?: () => Promise<boolean>;
|
||||
forceNewCredential: boolean;
|
||||
simpleSetup: boolean;
|
||||
runtime: AgentChannelRuntime;
|
||||
|
|
|
|||
|
|
@ -714,6 +714,7 @@ watch(
|
|||
:agent-id="agentId"
|
||||
:personalisation="personalisation"
|
||||
:ensure-agent-persisted="ensureAgentPersisted"
|
||||
:persist-channel="writeChannelConfig"
|
||||
:force-new-credential="false"
|
||||
:simple-setup="simpleSetup"
|
||||
:runtime="currentRuntime"
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@ import type { Icon, ICredentialType, INodeProperties } from 'n8n-workflow';
|
|||
const GRAPH = 'https://graph.microsoft.com';
|
||||
|
||||
/**
|
||||
* `Application.ReadWrite.All` registers the customer's Entra app. It is
|
||||
* admin-consent-only, so one tenant admin approves n8n once and every later
|
||||
* sign-in is prompt-free.
|
||||
* `Application.ReadWrite.All` registers the customer's Entra app, and
|
||||
* `AppCatalog.ReadWrite.All` publishes the Teams app straight to the
|
||||
* organisation's catalogue. Both are admin-consent-only, so one tenant admin
|
||||
* approves n8n once and every later sign-in is prompt-free. `AppCatalog.Submit`
|
||||
* is what a user without the Teams administrator role falls back to, for the
|
||||
* submission itself.
|
||||
*
|
||||
* Nothing here reads or writes anyone's installed apps: the package is
|
||||
* uploaded in the Teams client and n8n never looks for it afterwards.
|
||||
|
|
@ -27,6 +30,8 @@ const scopes = [
|
|||
'offline_access',
|
||||
`${GRAPH}/User.Read`,
|
||||
`${GRAPH}/Application.ReadWrite.All`,
|
||||
`${GRAPH}/AppCatalog.ReadWrite.All`,
|
||||
`${GRAPH}/AppCatalog.Submit`,
|
||||
];
|
||||
|
||||
export class MicrosoftTeamsManagerOAuth2Api implements ICredentialType {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ describe('MicrosoftTeamsManagerOAuth2Api Credential', () => {
|
|||
expect.arrayContaining([
|
||||
'offline_access',
|
||||
'https://graph.microsoft.com/Application.ReadWrite.All',
|
||||
'https://graph.microsoft.com/AppCatalog.ReadWrite.All',
|
||||
'https://graph.microsoft.com/AppCatalog.Submit',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
|
@ -56,15 +58,14 @@ describe('MicrosoftTeamsManagerOAuth2Api Credential', () => {
|
|||
});
|
||||
|
||||
/**
|
||||
* The package is uploaded in the Teams client and n8n never looks for it
|
||||
* afterwards, so it asks for nothing over anyone's installed apps -- and no
|
||||
* catalogue write either.
|
||||
* n8n publishes the app to the organisation catalogue. It installs nothing
|
||||
* for anyone and never reads back what they have, so neither write nor read
|
||||
* over their apps is asked for.
|
||||
*/
|
||||
it("asks for nothing over the user's apps", () => {
|
||||
const scope = String(defaultOf('scope'));
|
||||
|
||||
expect(scope).not.toContain('TeamsAppInstallation');
|
||||
expect(scope).not.toContain('AppCatalog');
|
||||
});
|
||||
|
||||
it('keeps a refresh token, without which the Azure token cannot be minted', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user