diff --git a/packages/integration-sdk-entity-validator/src/__tests__/validator.test.ts b/packages/integration-sdk-entity-validator/src/__tests__/validator.test.ts index bf7fbaebb..380339a46 100644 --- a/packages/integration-sdk-entity-validator/src/__tests__/validator.test.ts +++ b/packages/integration-sdk-entity-validator/src/__tests__/validator.test.ts @@ -75,6 +75,7 @@ describe('validator', () => { ).toEqual({ isValid: true, errors: null, + warnings: null, skippedSchemas: [ { schemaId: '#type', @@ -104,6 +105,7 @@ describe('validator', () => { ).toEqual({ isValid: true, errors: null, + warnings: null, skippedSchemas: [ { schemaId: '#type', @@ -134,6 +136,7 @@ describe('validator', () => { property: '_key', }, ], + warnings: null, skippedSchemas: [ { schemaId: '#type', @@ -155,6 +158,7 @@ describe('validator', () => { ).toEqual({ isValid: true, errors: null, + warnings: null, skippedSchemas: [ { schemaId: '#GraphObject', @@ -180,6 +184,7 @@ describe('validator', () => { ).toEqual({ isValid: true, errors: null, + warnings: null, skippedSchemas: [ { schemaId: '#GraphObject', @@ -208,6 +213,7 @@ describe('validator', () => { ).toEqual({ isValid: true, errors: null, + warnings: null, skippedSchemas: null, }); }); @@ -229,6 +235,7 @@ describe('validator', () => { property: '_key', }, ], + warnings: null, skippedSchemas: [ { schemaId: '#GraphObject', @@ -257,6 +264,7 @@ describe('validator', () => { property: '_key', }, ], + warnings: null, skippedSchemas: [ { schemaId: '#GraphObject', @@ -279,6 +287,7 @@ describe('validator', () => { ).toEqual({ isValid: true, errors: null, + warnings: null, skippedSchemas: [ { schemaId: '#GraphObject', @@ -301,6 +310,7 @@ describe('validator', () => { ).toEqual({ isValid: true, errors: null, + warnings: null, skippedSchemas: [ { schemaId: '#GraphObject', @@ -323,6 +333,7 @@ describe('validator', () => { ).toEqual({ isValid: true, errors: null, + warnings: null, skippedSchemas: [ { schemaId: '#GraphObject', @@ -352,6 +363,7 @@ describe('validator', () => { validation: 'format', }, ], + warnings: null, skippedSchemas: [ { schemaId: '#GraphObject', @@ -362,3 +374,209 @@ describe('validator', () => { }); }); }); + +describe('validator — permissive enum properties', () => { + // In-memory NHI-shaped schema. The shape mirrors the data-model's NHI class + // additions in M001/S02: `nhiType`, `nhiOwnerStatus`, `aiConfidence` are + // enum-constrained; `isAi` is a bare boolean (type, not enum). + const NHI_SCHEMA = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: '#NHI', + type: 'object', + properties: { + _key: { type: 'string', minLength: 10 }, + _class: { + oneOf: [ + { type: 'string', minLength: 2 }, + { + type: 'array', + minItems: 1, + items: { type: 'string', minLength: 2 }, + }, + ], + }, + _type: { type: 'string', minLength: 3 }, + nhiType: { + type: 'string', + enum: ['service_account', 'api_key', 'workload_identity'], + }, + nhiOwnerStatus: { + type: 'string', + enum: ['active', 'inactive', 'orphaned'], + }, + aiConfidence: { + type: 'string', + enum: ['high', 'medium', 'low'], + }, + isAi: { type: 'boolean' }, + }, + required: ['_key', '_class', '_type'], + }; + + const validNhiEntity = { + _class: ['NHI'], + _type: 'NHI', + _key: '0123456789', + nhiType: 'service_account', + nhiOwnerStatus: 'active', + aiConfidence: 'high', + isAi: true, + }; + + test('valid NHI entity produces no errors and no warnings', () => { + const validator = new EntityValidator({ schemas: [NHI_SCHEMA] }); + expect(validator.validateEntity(validNhiEntity)).toEqual({ + isValid: true, + errors: null, + warnings: null, + skippedSchemas: [ + { schemaId: '#NHI', reason: 'type-already-validated', type: 'class' }, + ], + }); + }); + + test('unknown nhiType becomes a warning, not an error', () => { + const validator = new EntityValidator({ schemas: [NHI_SCHEMA] }); + const result = validator.validateEntity({ + ...validNhiEntity, + nhiType: 'unknown_subtype', + }); + expect(result.isValid).toBe(true); + expect(result.errors).toBeNull(); + expect(result.warnings).toEqual([ + { + schemaId: '#NHI', + property: 'nhiType', + message: expect.stringContaining('must be equal to one of'), + validation: 'enum', + }, + ]); + }); + + test('unknown nhiOwnerStatus becomes a warning', () => { + const validator = new EntityValidator({ schemas: [NHI_SCHEMA] }); + const result = validator.validateEntity({ + ...validNhiEntity, + nhiOwnerStatus: 'mystery', + }); + expect(result.isValid).toBe(true); + expect(result.errors).toBeNull(); + expect(result.warnings).toHaveLength(1); + expect(result.warnings![0].property).toBe('nhiOwnerStatus'); + expect(result.warnings![0].validation).toBe('enum'); + }); + + test('unknown aiConfidence becomes a warning', () => { + const validator = new EntityValidator({ schemas: [NHI_SCHEMA] }); + const result = validator.validateEntity({ + ...validNhiEntity, + aiConfidence: 'extremely high', + }); + expect(result.isValid).toBe(true); + expect(result.warnings).toHaveLength(1); + expect(result.warnings![0].property).toBe('aiConfidence'); + }); + + test('type mismatch on a permissive property still hard-errors', () => { + // isAi: 'yes' is a string where boolean is required. The property is not + // in the permissive set anyway, but the point is: keyword === 'type' is + // not 'enum', so even a permissive property would still hard-fail here. + const validator = new EntityValidator({ schemas: [NHI_SCHEMA] }); + const result = validator.validateEntity({ + ...validNhiEntity, + isAi: 'yes', + }); + expect(result.isValid).toBe(false); + expect(result.warnings).toBeNull(); + expect(result.errors).toEqual([ + { + schemaId: '#NHI', + property: 'isAi', + message: expect.stringContaining('must be boolean'), + validation: 'type', + }, + ]); + }); + + test('type mismatch on a permissive enum property is still an error', () => { + // nhiType is in the permissive set, but a number-shaped value triggers + // keyword === 'type' (not 'enum'), so it must hard-error. + const validator = new EntityValidator({ schemas: [NHI_SCHEMA] }); + const result = validator.validateEntity({ + ...validNhiEntity, + nhiType: 42, + }); + expect(result.isValid).toBe(false); + expect(result.errors).toEqual([ + { + schemaId: '#NHI', + property: 'nhiType', + message: expect.stringContaining('must be string'), + validation: 'type', + }, + ]); + }); + + test('mixed: permissive enum violation + missing _key produces both warnings and errors', () => { + const validator = new EntityValidator({ schemas: [NHI_SCHEMA] }); + const { _key, ...entityWithoutKey } = validNhiEntity; + const result = validator.validateEntity({ + ...entityWithoutKey, + nhiType: 'unknown_subtype', + }); + expect(result.isValid).toBe(false); + expect(result.errors).toEqual([ + { + schemaId: '#NHI', + property: '_key', + message: "must have required property '_key'", + validation: 'required', + }, + ]); + expect(result.warnings).toEqual([ + { + schemaId: '#NHI', + property: 'nhiType', + message: expect.stringContaining('must be equal to one of'), + validation: 'enum', + }, + ]); + }); + + test('permissiveEnumProperties: [] makes enum violations hard-error again', () => { + const validator = new EntityValidator({ + schemas: [NHI_SCHEMA], + permissiveEnumProperties: [], + }); + const result = validator.validateEntity({ + ...validNhiEntity, + nhiType: 'unknown_subtype', + }); + expect(result.isValid).toBe(false); + expect(result.warnings).toBeNull(); + expect(result.errors).toEqual([ + { + schemaId: '#NHI', + property: 'nhiType', + message: expect.stringContaining('must be equal to one of'), + validation: 'enum', + }, + ]); + }); + + test('permissiveEnumProperties can be extended with additional properties', () => { + const validator = new EntityValidator({ + schemas: [NHI_SCHEMA], + permissiveEnumProperties: ['nhiType', 'aiConfidence', 'customField'], + }); + // nhiOwnerStatus is no longer permissive in this configuration. + const result = validator.validateEntity({ + ...validNhiEntity, + nhiOwnerStatus: 'mystery', + }); + expect(result.isValid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors![0].property).toBe('nhiOwnerStatus'); + expect(result.errors![0].validation).toBe('enum'); + }); +}); diff --git a/packages/integration-sdk-entity-validator/src/index.ts b/packages/integration-sdk-entity-validator/src/index.ts index d381b2380..6639f34c8 100644 --- a/packages/integration-sdk-entity-validator/src/index.ts +++ b/packages/integration-sdk-entity-validator/src/index.ts @@ -4,4 +4,8 @@ export { isEntityValidationError, type EntityValidationError, } from './entityValidationError'; -export { getValidator, getValidatorSync } from './singleton'; +export { + getValidator, + getValidatorSync, + setSchemaSingleton, +} from './singleton'; diff --git a/packages/integration-sdk-entity-validator/src/validator.ts b/packages/integration-sdk-entity-validator/src/validator.ts index 4111dcb9d..0abbedc6e 100644 --- a/packages/integration-sdk-entity-validator/src/validator.ts +++ b/packages/integration-sdk-entity-validator/src/validator.ts @@ -12,6 +12,7 @@ import { assertEntity } from './assertEntity'; type ValidateEntityResult = { isValid: boolean; errors: EntityValidationError[] | null; + warnings: EntityValidationError[] | null; skippedSchemas: SkippedSchema[] | null; }; @@ -25,6 +26,19 @@ type ValidateEntityOptions = { forceClassValidationWithValidatedType?: boolean; }; +/** + * Properties whose `enum` violations are reclassified as warnings instead of + * errors by default. New NHI subtypes, AI platforms, and ownership statuses + * appear in real integrations before the data-model is published, so a strict + * enum failure here would block ingestion. Type mismatches on these same + * properties still hard-fail. + */ +export const DEFAULT_PERMISSIVE_ENUM_PROPERTIES: readonly string[] = [ + 'nhiType', + 'nhiOwnerStatus', + 'aiConfidence', +]; + const convertUnknownErrorToEntityValidationError = ( error: unknown, ): EntityValidationError => { @@ -49,10 +63,49 @@ const convertUnknownErrorToEntityValidationError = ( }; }; +/** + * Validates entities against AJV-compiled JSON schemas keyed by `_type` and + * `_class`. Returns `{ isValid, errors, warnings, skippedSchemas }`. + * + * Enum violations on properties listed in `permissiveEnumProperties` (default: + * {@link DEFAULT_PERMISSIVE_ENUM_PROPERTIES}) are routed to `warnings` and do + * **not** flip `isValid` to `false`. Every other violation — including type + * mismatches on those same properties, missing required fields, and enum + * violations on properties outside the permissive set — still hard-fails. + * + * The `warnings` field is purely additive: callers that previously inspected + * only `isValid`/`errors` continue to behave identically. To opt out of + * permissive behavior entirely, pass `permissiveEnumProperties: []`. + * + * @example + * ```ts + * const validator = new EntityValidator({ schemas }); + * + * // Unknown NHI subtype → warning, entity accepted. + * validator.validateEntity({ _type: 'svc_account', _class: 'User', nhiType: 'novel_kind' }); + * // → { isValid: true, errors: null, warnings: [{ property: 'nhiType', ... }], ... } + * + * // Type mismatch on the same property → hard error. + * validator.validateEntity({ _type: 'svc_account', _class: 'User', nhiType: 42 }); + * // → { isValid: false, errors: [{ property: 'nhiType', validation: 'type', ... }], ... } + * ``` + */ export class EntityValidator { private ajvInstance: Ajv; - - constructor({ schemas }: { schemas?: AnySchema[] }) { + private permissiveEnumProperties: ReadonlySet; + + constructor({ + schemas, + permissiveEnumProperties = DEFAULT_PERMISSIVE_ENUM_PROPERTIES, + }: { + schemas?: AnySchema[]; + /** + * Property names whose `enum` violations are partitioned into + * `warnings` instead of `errors`. Pass `[]` to opt out of permissive + * behavior entirely. Defaults to {@link DEFAULT_PERMISSIVE_ENUM_PROPERTIES}. + */ + permissiveEnumProperties?: readonly string[]; + }) { this.ajvInstance = addJ1Formats( addFormats( new Ajv({ @@ -61,6 +114,7 @@ export class EntityValidator { }), ), ); + this.permissiveEnumProperties = new Set(permissiveEnumProperties); if (schemas) { this.addSchemas(schemas); @@ -87,6 +141,7 @@ export class EntityValidator { }: ValidateEntityOptions = {}, ): ValidateEntityResult { const errors: EntityValidationError[] = []; + const warnings: EntityValidationError[] = []; const skippedSchemas: SkippedSchema[] = []; try { @@ -126,11 +181,21 @@ export class EntityValidator { const isValid = validator(entity); if (!isValid) { - errors.push( - ...(validator.errors?.map((error) => - ajvErrorToEntityValidationError(schemaId, error), - ) ?? []), - ); + for (const ajvError of validator.errors ?? []) { + const validationError = ajvErrorToEntityValidationError( + schemaId, + ajvError, + ); + if ( + ajvError.keyword === 'enum' && + typeof validationError.property === 'string' && + this.permissiveEnumProperties.has(validationError.property) + ) { + warnings.push(validationError); + } else { + errors.push(validationError); + } + } } if (type === 'type' && !forceClassValidationWithValidatedType) { @@ -155,6 +220,7 @@ export class EntityValidator { return { isValid: errors.length === 0, errors: errors.length ? errors : null, + warnings: warnings.length ? warnings : null, skippedSchemas: skippedSchemas.length ? skippedSchemas : null, }; } diff --git a/packages/integration-sdk-testing/src/__tests__/jest.test.ts b/packages/integration-sdk-testing/src/__tests__/jest.test.ts index 176b6528b..324caf4d1 100644 --- a/packages/integration-sdk-testing/src/__tests__/jest.test.ts +++ b/packages/integration-sdk-testing/src/__tests__/jest.test.ts @@ -652,6 +652,115 @@ describe('#toMatchDataModelSchema', () => { expect(result.pass).toBe(false); }); + + test('should pass and emit a console.warn when entity violates a permissive enum (aiConfidence)', () => { + const StringEnum = (values: [...T]) => + SchemaType.Unsafe({ + type: 'string', + enum: values, + }); + const [METADATA, createTestEntity] = createEntityMetadata({ + resourceName: 'AI Tool', + _class: ['Service'], + _type: 'permissive_enum_warning_test_pass', + description: 'Schema with a permissive enum property.', + schema: SchemaType.Object({ + aiConfidence: StringEnum(['high', 'medium', 'low']), + }), + }); + + const entity = createTestEntity({ + name: 'thing', + function: ['other'], + _key: 'permissive-warn-pass-1', + displayName: 'Thing', + category: ['software'], + aiConfidence: 'extreme' as unknown as 'high' | 'medium' | 'low', + }); + + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = toMatchDataModelSchema(entity, METADATA); + expect(result.pass).toBe(true); + expect(warnSpy).toHaveBeenCalledTimes(1); + const warnMessage = warnSpy.mock.calls[0][0] as string; + expect(warnMessage).toContain('aiConfidence'); + expect(warnMessage).toContain('permissive enum violations'); + } finally { + warnSpy.mockRestore(); + } + }); + + test('should fail when entity has a hard error even if it also has a permissive warning', () => { + const StringEnum = (values: [...T]) => + SchemaType.Unsafe({ + type: 'string', + enum: values, + }); + const [METADATA, createTestEntity] = createEntityMetadata({ + resourceName: 'AI Tool', + _class: ['Service'], + _type: 'permissive_enum_warning_test_mixed', + description: 'Schema with both a strict enum and a permissive enum.', + schema: SchemaType.Object({ + state: StringEnum(['ENABLED', 'DISABLED']), + aiConfidence: StringEnum(['high', 'medium', 'low']), + }), + }); + + const entity = createTestEntity({ + name: 'thing', + function: ['other'], + _key: 'permissive-warn-mixed-1', + displayName: 'Thing', + category: ['software'], + state: 'BOGUS' as unknown as 'ENABLED' | 'DISABLED', + aiConfidence: 'extreme' as unknown as 'high' | 'medium' | 'low', + }); + + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = toMatchDataModelSchema(entity, METADATA); + expect(result.pass).toBe(false); + const message = (result.message as () => string)(); + expect(message).toContain('state'); + // warning is still surfaced even though we fail + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('aiConfidence'); + } finally { + warnSpy.mockRestore(); + } + }); + + test('should not emit a console.warn when entity is fully clean', () => { + const [API_SERVICE, createApiService] = createEntityMetadata({ + resourceName: 'Google Cloud API Service', + _class: ['Service'], + _type: 'permissive_enum_clean_test', + description: 'Clean entity should produce no warnings.', + schema: SchemaType.Object({ + enabled: SchemaType.Boolean(), + }), + }); + + const entity = createApiService({ + name: 'clean', + function: ['other'], + _key: 'permissive-warn-clean-1', + displayName: 'Clean', + category: ['infrastructure'], + enabled: true, + }); + + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = toMatchDataModelSchema(entity, API_SERVICE); + expect(result.pass).toBe(true); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); }); describe('#toMatchDirectRelationshipSchema', () => { @@ -1402,7 +1511,7 @@ describe('#toMatchEntityStepMetadata', () => { expect(result1).toMatchObject({ pass: false }); expect(result1.message()).toMatch( - 'Error validating object with data model schema: #google_cloud_api_service:usageRequirements:must be array', + 'Error validating object with data model schema: [0] #google_cloud_api_service:usageRequirements:must be array', ); const entity2 = createApiService({ @@ -1435,7 +1544,7 @@ describe('#toMatchEntityStepMetadata', () => { ); expect(result2).toMatchObject({ pass: false }); expect(result2.message()).toMatch( - "Error validating object with data model schema: #google_cloud_api_service:category:must have required property 'category'", + "Error validating object with data model schema: [0] #google_cloud_api_service:category:must have required property 'category'", ); }); }); diff --git a/packages/integration-sdk-testing/src/jest.ts b/packages/integration-sdk-testing/src/jest.ts index f2a554692..0388d8e06 100644 --- a/packages/integration-sdk-testing/src/jest.ts +++ b/packages/integration-sdk-testing/src/jest.ts @@ -77,6 +77,16 @@ declare global { * }); * * expect(collectedEntities).toMatchDataModelSchema(USER_ENTITY); + * ``` + * + * Permissive enum violations on `nhiType`, `nhiOwnerStatus`, and + * `aiConfidence` (the defaults configured on the underlying + * `EntityValidator`) are emitted via a single `console.warn` and do **not** + * fail the assertion. Hard errors — type mismatches, missing required + * fields, and enum violations on any other property — fail as before. + * + * To assert on the warning in tests, spy on it: + * `jest.spyOn(console, 'warn').mockImplementation(() => {})`. */ toMatchDataModelSchema(metadata: StepEntityMetadata): R; @@ -188,6 +198,19 @@ declare global { } } +/** + * Jest matcher implementation that delegates to `EntityValidator` to validate + * one or more entities against the schema declared on `StepEntityMetadata`. + * + * Permissive enum violations (default: `nhiType`, `nhiOwnerStatus`, + * `aiConfidence`) are surfaced via a single `console.warn` per assertion and + * the matcher still passes. Hard errors (type mismatches, missing required + * fields, enum violations on non-permissive properties) cause the matcher to + * fail with the aggregated error list. + * + * Tests that want to assert on the warning should + * `jest.spyOn(console, 'warn')` before invoking the matcher. + */ export function toMatchDataModelSchema( received: T | T[], metadata: StepEntityMetadata, @@ -205,17 +228,39 @@ export function toMatchDataModelSchema( received = Array.isArray(received) ? received : [received]; + const allErrors: string[] = []; + const allWarnings: string[] = []; + for (let i = 0; i < received.length; i++) { - // if valid - const { isValid, errors = [] } = entityValidator.validateEntity( - received[i], - ); + const { errors, warnings } = entityValidator.validateEntity(received[i]); - if (isValid) continue; + if (errors) { + for (const e of errors) { + allErrors.push( + `[${i}] ${e.schemaId}:${String(e.property)}:${e.message}`, + ); + } + } + if (warnings) { + for (const w of warnings) { + allWarnings.push( + `[${i}] ${w.schemaId}:${String(w.property)}:${w.message}`, + ); + } + } + } + + if (allWarnings.length) { + // eslint-disable-next-line no-console + console.warn( + `toMatchDataModelSchema: permissive enum violations (entity will be accepted): ${allWarnings.join(', ')}`, + ); + } + if (allErrors.length) { return { message: () => - `Error validating object with data model schema: ${errors?.map((e) => `${e.schemaId}:${String(e.property)}:${e.message}`).join(', ')}`, + `Error validating object with data model schema: ${allErrors.join(', ')}`, pass: false, }; }