Surface structured validation errors during application install (#19787)
## Summary - Add `WorkspaceMigrationGraphqlApiExceptionInterceptor` to `MarketplaceResolver` and `ApplicationInstallResolver` so validation failures during app install return `METADATA_VALIDATION_FAILED` with structured `extensions.errors` instead of generic `INTERNAL_SERVER_ERROR` - Update SDK `installTarballApp()` to pass the full GraphQL error object (including extensions) through the install flow - Add `formatInstallValidationErrors` utility to format structured validation errors for CLI output - Add integration test verifying structured error responses for invalid navigation menu items and view fields --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+8
-1
@@ -1,4 +1,9 @@
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import {
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UsePipes,
|
||||
} from '@nestjs/common';
|
||||
import { Args, Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
@@ -14,10 +19,12 @@ import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspac
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
|
||||
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@MetadataResolver()
|
||||
@UseFilters(ApplicationExceptionFilter, AuthGraphqlApiExceptionFilter)
|
||||
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
export class ApplicationInstallResolver {
|
||||
constructor(
|
||||
|
||||
+3
-1
@@ -1,4 +1,4 @@
|
||||
import { UseFilters, UseGuards } from '@nestjs/common';
|
||||
import { UseFilters, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { Args, Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
@@ -13,6 +13,7 @@ import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorat
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
|
||||
import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/application/application-marketplace/crons/marketplace-catalog-sync.cron.job';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
@@ -20,6 +21,7 @@ import { MessageQueueService } from 'src/engine/core-modules/message-queue/servi
|
||||
|
||||
@MetadataResolver()
|
||||
@UseFilters(ApplicationRegistrationExceptionFilter)
|
||||
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
|
||||
@UseGuards(WorkspaceAuthGuard, NoPermissionGuard)
|
||||
export class MarketplaceResolver {
|
||||
constructor(
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
import crypto from 'crypto';
|
||||
import { promises as fs } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import * as tar from 'tar';
|
||||
import { installApplication } from 'test/integration/metadata/suites/application/utils/install-application.util';
|
||||
import { uploadAppTarball } from 'test/integration/metadata/suites/application/utils/upload-app-tarball.util';
|
||||
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
|
||||
import { type DataSource } from 'typeorm';
|
||||
|
||||
const createTestTarball = async (
|
||||
files: Record<string, string>,
|
||||
): Promise<Buffer> => {
|
||||
const tempId = crypto.randomUUID();
|
||||
const sourceDir = join(tmpdir(), `test-tarball-src-${tempId}`);
|
||||
const tarballPath = join(tmpdir(), `test-tarball-${tempId}.tar.gz`);
|
||||
|
||||
await fs.mkdir(sourceDir, { recursive: true });
|
||||
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
const filePath = join(sourceDir, name);
|
||||
const dir = filePath.substring(0, filePath.lastIndexOf('/'));
|
||||
|
||||
if (dir !== sourceDir) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
await fs.writeFile(filePath, content);
|
||||
}
|
||||
|
||||
await tar.create(
|
||||
{
|
||||
file: tarballPath,
|
||||
gzip: true,
|
||||
cwd: sourceDir,
|
||||
},
|
||||
Object.keys(files),
|
||||
);
|
||||
|
||||
const buffer = await fs.readFile(tarballPath);
|
||||
|
||||
await fs.rm(sourceDir, { recursive: true, force: true });
|
||||
await fs.rm(tarballPath, { force: true });
|
||||
|
||||
return buffer;
|
||||
};
|
||||
|
||||
const buildManifestWithCrossEntityIdentifierConflict = (
|
||||
universalIdentifier: string,
|
||||
roleUniversalIdentifier: string,
|
||||
duplicatedUniversalIdentifier: string,
|
||||
) =>
|
||||
JSON.stringify({
|
||||
application: {
|
||||
universalIdentifier,
|
||||
displayName: 'Test App With Cross Entity Identifier Conflict',
|
||||
description:
|
||||
'A test app whose manifest reuses a universalIdentifier across entity types',
|
||||
icon: 'IconTestPipe',
|
||||
defaultRoleUniversalIdentifier: roleUniversalIdentifier,
|
||||
applicationVariables: {},
|
||||
packageJsonChecksum: null,
|
||||
yarnLockChecksum: null,
|
||||
},
|
||||
roles: [
|
||||
{
|
||||
universalIdentifier: roleUniversalIdentifier,
|
||||
label: 'First Role',
|
||||
description: 'First role',
|
||||
},
|
||||
{
|
||||
universalIdentifier: duplicatedUniversalIdentifier,
|
||||
label: 'Second Role',
|
||||
description: 'Second role',
|
||||
objectPermissions: [
|
||||
{
|
||||
universalIdentifier: duplicatedUniversalIdentifier,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECTS.company.universalIdentifier,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: false,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
skills: [],
|
||||
agents: [],
|
||||
objects: [],
|
||||
fields: [],
|
||||
logicFunctions: [],
|
||||
frontComponents: [],
|
||||
publicAssets: [],
|
||||
views: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
});
|
||||
|
||||
describe('Install application should return structured validation errors', () => {
|
||||
let ds: DataSource;
|
||||
const createdRegistrationIds: string[] = [];
|
||||
const createdApplicationUniversalIdentifiers: string[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useRealTimers();
|
||||
ds = globalThis.testDataSource;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const uid of createdApplicationUniversalIdentifiers) {
|
||||
await ds.query(
|
||||
`DELETE FROM core."file" WHERE "applicationId" IN (
|
||||
SELECT id FROM core."application" WHERE "universalIdentifier" = $1
|
||||
)`,
|
||||
[uid],
|
||||
);
|
||||
|
||||
await ds.query(
|
||||
`DELETE FROM core."application" WHERE "universalIdentifier" = $1`,
|
||||
[uid],
|
||||
);
|
||||
}
|
||||
|
||||
for (const id of createdRegistrationIds) {
|
||||
await ds.query(
|
||||
`DELETE FROM core."applicationRegistration" WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
}
|
||||
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
it('should return METADATA_VALIDATION_FAILED with structured errors when installing an app whose manifest has validation errors', async () => {
|
||||
const universalIdentifier = crypto.randomUUID();
|
||||
const roleUniversalIdentifier = crypto.randomUUID();
|
||||
const duplicatedUniversalIdentifier = crypto.randomUUID();
|
||||
const manifest = buildManifestWithCrossEntityIdentifierConflict(
|
||||
universalIdentifier,
|
||||
roleUniversalIdentifier,
|
||||
duplicatedUniversalIdentifier,
|
||||
);
|
||||
|
||||
const tarball = await createTestTarball({
|
||||
'manifest.json': manifest,
|
||||
'package.json': JSON.stringify({
|
||||
name: 'test-cross-entity-identifier-conflict-app',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
});
|
||||
|
||||
const uploadResult = await uploadAppTarball({
|
||||
tarballBuffer: tarball,
|
||||
universalIdentifier,
|
||||
});
|
||||
|
||||
expect(uploadResult.errors).toBeUndefined();
|
||||
expect(uploadResult.data?.uploadAppTarball.id).toBeDefined();
|
||||
|
||||
const registrationId = uploadResult.data!.uploadAppTarball.id;
|
||||
|
||||
createdRegistrationIds.push(registrationId);
|
||||
createdApplicationUniversalIdentifiers.push(universalIdentifier);
|
||||
|
||||
const { errors } = await installApplication({
|
||||
input: {
|
||||
appRegistrationId: registrationId,
|
||||
},
|
||||
expectToFail: true,
|
||||
});
|
||||
|
||||
expect(errors).toBeDefined();
|
||||
expect(errors.length).toBe(1);
|
||||
|
||||
const [error] = errors;
|
||||
|
||||
expect(error.extensions.code).toBe('METADATA_VALIDATION_FAILED');
|
||||
expect(error.extensions.errors).toBeDefined();
|
||||
expect(error.extensions.summary).toBeDefined();
|
||||
expect(error.extensions.summary.totalErrors).toBeGreaterThan(0);
|
||||
expect(error.extensions.message).toMatch(/Validation failed for/);
|
||||
}, 120000);
|
||||
});
|
||||
Reference in New Issue
Block a user