diff --git a/packages/integration-sdk-core/src/data/__tests__/createIntegrationEntity.test.ts b/packages/integration-sdk-core/src/data/__tests__/createIntegrationEntity.test.ts index 7cab0df7e..426ea12df 100644 --- a/packages/integration-sdk-core/src/data/__tests__/createIntegrationEntity.test.ts +++ b/packages/integration-sdk-core/src/data/__tests__/createIntegrationEntity.test.ts @@ -1,5 +1,7 @@ import { randomUUID as uuid } from 'crypto'; +import * as dataModel from '@jupiterone/data-model'; + import { createIntegrationEntity, IntegrationEntityData, @@ -7,6 +9,55 @@ import { schemaWhitelists, } from '../createIntegrationEntity'; +// Inline NHI class schema. The published @jupiterone/data-model package does +// not yet ship NHI (AIASM-15 will publish it). For verification we register an +// equivalent of data-model/packages/jupiterone-data-model/src/class_schemas/NHI.json +// so getSchema('NHI') resolves during these tests. +const NHI_CLASS_SCHEMA = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: '#NHI', + description: 'Non-Human Identity test fixture mirroring data-model NHI.json', + type: 'object', + allOf: [ + { $ref: '#Entity' }, + { + properties: { + nhiType: { + type: 'string', + enum: [ + 'service_account', + 'credential', + 'secret', + 'oauth_app', + 'bot', + 'certificate', + 'api_key', + 'webhook', + 'ci_cd_identity', + ], + }, + isAi: { type: 'boolean' }, + aiConfidence: { + type: 'string', + enum: ['confirmed', 'high', 'medium', 'low'], + }, + aiPlatform: { type: 'string' }, + nhiOwnerStatus: { + type: 'string', + enum: ['assigned', 'unassigned', 'orphaned'], + }, + }, + required: [], + }, + ], +}; + +beforeAll(() => { + if (!dataModel.getSchema('NHI')) { + dataModel.IntegrationSchema.addSchema(NHI_CLASS_SCHEMA); + } +}); + const networkSourceData = { id: 'natural-identifier', environment: 'production', @@ -370,3 +421,211 @@ describe('schema validation off', () => { ).not.toThrow(); }); }); + +// AIASM-14: Verify multi-class entity validation for NHI combinations. +// These tests prove the SDK already supports `_class: [, 'NHI']` +// without code changes — the deliverable is a test suite that pins the +// behavior so future refactors don't regress it. +describe('AIASM-14: multi-class NHI combinations', () => { + const NHI_PROPERTIES = [ + 'nhiType', + 'isAi', + 'aiConfidence', + 'aiPlatform', + 'nhiOwnerStatus', + ]; + + describe('schemaWhitelistedPropertyNames unions properties from each class', () => { + // Entity-base properties present on every J1 class. + const ENTITY_BASE_PROPS = ['id', 'name', 'displayName']; + + test.each([ + ['User', ['username', 'email']], + ['AccessKey', ['fingerprint', 'material', 'usage']], + ['Application', ['COTS', 'SaaS', 'license']], + // Certificate / Secret extend Entity directly without adding properties + // in @jupiterone/data-model 0.62.0 — assert only Entity-base + NHI. + ['Certificate', []], + ['Secret', []], + ])( + '%s + NHI: includes NHI metadata and primary-class properties', + (primaryClass, primarySpecificProps) => { + const props = schemaWhitelistedPropertyNames([primaryClass, 'NHI']); + expect(props).toIncludeAllMembers(NHI_PROPERTIES); + expect(props).toIncludeAllMembers(ENTITY_BASE_PROPS); + if (primarySpecificProps.length) { + expect(props).toIncludeAllMembers(primarySpecificProps); + } + }, + ); + }); + + describe('createIntegrationEntity preserves NHI properties for multi-class entities (BDD 14.5)', () => { + test('User + NHI: keeps nhiType plus standard User fields (BDD 14.1)', () => { + const entity = createIntegrationEntity({ + entityData: { + assign: { + _class: ['User', 'NHI'], + _type: 'gh_service_user', + _key: 'gh:svc:1', + }, + source: { + id: 'svc-1', + name: 'github-actions-bot', + username: 'gh-actions', + email: 'bot@example.com', + active: true, + nhiType: 'service_account', + isAi: false, + aiConfidence: 'low', + nhiOwnerStatus: 'assigned', + }, + }, + }); + + expect(entity._class).toEqual(['User', 'NHI']); + expect(entity).toMatchObject({ + username: 'gh-actions', + email: 'bot@example.com', + nhiType: 'service_account', + isAi: false, + aiConfidence: 'low', + nhiOwnerStatus: 'assigned', + }); + }); + + test('AccessKey + NHI: keeps nhiType=credential plus AccessKey-specific fingerprint (BDD 14.2)', () => { + const entity = createIntegrationEntity({ + entityData: { + assign: { + _class: ['AccessKey', 'NHI'], + _type: 'aws_iam_access_key', + _key: 'aws:ak:1', + }, + source: { + id: 'AKIA-EXAMPLE', + name: 'service-key', + fingerprint: 'sha256:deadbeef', + usage: 'signing', + nhiType: 'credential', + isAi: false, + }, + }, + }); + + expect(entity._class).toEqual(['AccessKey', 'NHI']); + expect(entity).toMatchObject({ + fingerprint: 'sha256:deadbeef', + usage: 'signing', + nhiType: 'credential', + isAi: false, + }); + }); + + test('Application + NHI: keeps nhiType=oauth_app and AI metadata', () => { + const entity = createIntegrationEntity({ + entityData: { + assign: { + _class: ['Application', 'NHI'], + _type: 'okta_oauth_app', + _key: 'okta:app:1', + }, + source: { + id: 'okta-app-1', + name: 'Anthropic Claude Bot', + SaaS: true, + license: 'commercial', + nhiType: 'oauth_app', + isAi: true, + aiConfidence: 'confirmed', + aiPlatform: 'anthropic', + }, + }, + }); + + expect(entity._class).toEqual(['Application', 'NHI']); + expect(entity).toMatchObject({ + SaaS: true, + license: 'commercial', + nhiType: 'oauth_app', + isAi: true, + aiConfidence: 'confirmed', + aiPlatform: 'anthropic', + }); + }); + + test('Certificate + NHI: keeps nhiType=certificate alongside Entity-base properties', () => { + const entity = createIntegrationEntity({ + entityData: { + assign: { + _class: ['Certificate', 'NHI'], + _type: 'tls_certificate', + _key: 'cert:1', + }, + source: { + id: 'cert-1', + name: 'service.example.com', + description: 'TLS cert for service', + nhiType: 'certificate', + }, + }, + }); + + expect(entity._class).toEqual(['Certificate', 'NHI']); + expect(entity).toMatchObject({ + name: 'service.example.com', + description: 'TLS cert for service', + nhiType: 'certificate', + }); + }); + + test('Secret + NHI: keeps nhiType=secret alongside Entity-base properties', () => { + const entity = createIntegrationEntity({ + entityData: { + assign: { + _class: ['Secret', 'NHI'], + _type: 'vault_secret', + _key: 'secret:1', + }, + source: { + id: 'secret-1', + name: 'db-password', + description: 'Production database password', + nhiType: 'secret', + nhiOwnerStatus: 'orphaned', + }, + }, + }); + + expect(entity._class).toEqual(['Secret', 'NHI']); + expect(entity).toMatchObject({ + name: 'db-password', + description: 'Production database password', + nhiType: 'secret', + nhiOwnerStatus: 'orphaned', + }); + }); + + test('omitting all NHI properties is allowed (BDD 14.4: optional)', () => { + const entity = createIntegrationEntity({ + entityData: { + assign: { + _class: ['User', 'NHI'], + _type: 'gh_service_user', + _key: 'gh:svc:2', + }, + source: { + id: 'svc-2', + name: 'no-metadata-yet', + username: 'no-meta', + }, + }, + }); + + expect(entity._class).toEqual(['User', 'NHI']); + for (const prop of NHI_PROPERTIES) { + expect(prop in entity).toBe(false); + } + }); + }); +}); diff --git a/packages/integration-sdk-core/src/data/__tests__/createIntegrationHelpers.test.ts b/packages/integration-sdk-core/src/data/__tests__/createIntegrationHelpers.test.ts index c4e48d4d7..d0bc417fa 100644 --- a/packages/integration-sdk-core/src/data/__tests__/createIntegrationHelpers.test.ts +++ b/packages/integration-sdk-core/src/data/__tests__/createIntegrationHelpers.test.ts @@ -1,9 +1,51 @@ +import { Type } from '@sinclair/typebox'; import { typeboxClassSchemaMap } from '@jupiterone/data-model'; import { createIntegrationHelpers } from '../createIntegrationHelpers'; import { EntityValidator } from '@jupiterone/integration-sdk-entity-validator'; import { entitySchemas } from '@jupiterone/data-model'; import { SchemaType } from '../..'; +// Inline NHI typebox schema used to verify multi-class+NHI behavior. The +// published @jupiterone/data-model 0.62.0 does not yet include NHI in +// typeboxClassSchemaMap (publishing is AIASM-15). Mirrors the JSON +// equivalent at data-model/.../class_schemas/NHI.json with NHI metadata +// declared as optional. +const NHI_TYPEBOX = Type.Object( + { + nhiType: Type.Optional( + Type.Union([ + Type.Literal('service_account'), + Type.Literal('credential'), + Type.Literal('secret'), + Type.Literal('oauth_app'), + Type.Literal('bot'), + Type.Literal('certificate'), + Type.Literal('api_key'), + Type.Literal('webhook'), + Type.Literal('ci_cd_identity'), + ]), + ), + isAi: Type.Optional(Type.Boolean()), + aiConfidence: Type.Optional( + Type.Union([ + Type.Literal('confirmed'), + Type.Literal('high'), + Type.Literal('medium'), + Type.Literal('low'), + ]), + ), + aiPlatform: Type.Optional(Type.String()), + nhiOwnerStatus: Type.Optional( + Type.Union([ + Type.Literal('assigned'), + Type.Literal('unassigned'), + Type.Literal('orphaned'), + ]), + ), + }, + { $id: '#NHI' }, +); + describe('createIntegrationHelpers', () => { const { createEntityType, @@ -362,4 +404,88 @@ describe('createIntegrationHelpers', () => { expect(errors).toEqual(null); }); + + // AIASM-14: createMultiClassEntityMetadata works with NHI as a secondary + // class. Verifies BDD scenario 14.3 — that the helper accepts an NHI + // schema, produces a multi-class JSON schema, and the resulting + // createEntity callable returns an entity that validates against the + // combined schema. + test('createMultiClassEntityMetadata with NHI: User + NHI multi-class entity validates (BDD 14.3, 14.1)', () => { + const [SVC_USER, createSvcUser] = createMultiClassEntityMetadata({ + resourceName: 'ServiceUser', + _type: 'gh_service_user', + _class: [typeboxClassSchemaMap['User'], NHI_TYPEBOX], + description: 'GitHub service user (User + NHI)', + schema: SchemaType.Object({ + id: SchemaType.String(), + name: SchemaType.String(), + }), + }); + + expect(SVC_USER._class).toEqual(['User', 'NHI']); + expect(SVC_USER._type).toBe('gh_service_user'); + // Schema is a JSON-Schema-shaped object with allOf containing $refs to + // each class plus the literal _class/_type and the user-supplied schema. + expect(SVC_USER.schema).toMatchObject({ + $id: '#gh_service_user', + allOf: expect.arrayContaining([{ $ref: '#User' }, { $ref: '#NHI' }]), + }); + + const entity = createSvcUser({ + id: 'svc-1', + name: 'github-actions-bot', + // Satisfy User-schema required fields. Bots/service-accounts in the + // wild don't always have firstName/lastName — that schema rigidity + // is a data-model concern, not in scope for AIASM-14. + _key: 'gh:svc:account:1', + displayName: 'GitHub Actions', + firstName: 'GitHub', + lastName: 'Actions', + email: ['bot@example.com'], + shortLoginId: ['gh-actions'], + username: ['gh-actions'], + nhiType: 'service_account', + isAi: false, + aiConfidence: 'low', + nhiOwnerStatus: 'assigned', + }); + expect(entity._class).toEqual(['User', 'NHI']); + expect(entity._type).toBe('gh_service_user'); + + // Validate against the produced schema. The validator needs both class + // schemas registered so the $refs resolve. + const validator = new EntityValidator({ + schemas: [...Object.values(entitySchemas), NHI_TYPEBOX as never], + }); + validator.addSchemas(SVC_USER.schema); + const { errors } = validator.validateEntity(entity); + expect(errors).toEqual(null); + }); + + test('createMultiClassEntityMetadata with NHI: AccessKey + NHI variant (BDD 14.2)', () => { + const [SVC_KEY, createSvcKey] = createMultiClassEntityMetadata({ + resourceName: 'ServiceKey', + _type: 'aws_iam_access_key', + _class: [typeboxClassSchemaMap['AccessKey'], NHI_TYPEBOX], + description: 'AWS IAM access key for an automated workload', + schema: SchemaType.Object({ + id: SchemaType.String(), + name: SchemaType.String(), + }), + }); + + expect(SVC_KEY._class).toEqual(['AccessKey', 'NHI']); + expect(SVC_KEY.schema).toMatchObject({ + allOf: expect.arrayContaining([{ $ref: '#AccessKey' }, { $ref: '#NHI' }]), + }); + + const entity = createSvcKey({ + id: 'AKIA-EXAMPLE', + name: 'service-key', + _key: 'aws:ak:1', + nhiType: 'credential', + }); + expect(entity._class).toEqual(['AccessKey', 'NHI']); + expect(entity.nhiType).toBe('credential'); + }); }); diff --git a/packages/integration-sdk-testing/src/__tests__/jest.test.ts b/packages/integration-sdk-testing/src/__tests__/jest.test.ts index 176b6528b..44ff3bc79 100644 --- a/packages/integration-sdk-testing/src/__tests__/jest.test.ts +++ b/packages/integration-sdk-testing/src/__tests__/jest.test.ts @@ -13,7 +13,9 @@ import { createIntegrationHelpers, SchemaType, } from '@jupiterone/integration-sdk-core'; +import * as dataModel from '@jupiterone/data-model'; import { typeboxClassSchemaMap as classSchemaMap } from '@jupiterone/data-model'; +import { getValidatorSync } from '@jupiterone/integration-sdk-entity-validator'; import { getMockIntegrationStep } from '@jupiterone/integration-sdk-private-test-utils'; import { randomUUID as uuid } from 'crypto'; import { toMatchStepMetadata } from '..'; @@ -652,6 +654,114 @@ describe('#toMatchDataModelSchema', () => { expect(result.pass).toBe(false); }); + + // AIASM-14 / BDD 14.4: toMatchDataModelSchema validates a User+NHI + // multi-class entity. Both class schemas must be exercised, and the + // entity must pass. + describe('AIASM-14: multi-class NHI', () => { + const NHI_JSON_SCHEMA = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: '#NHI', + type: 'object', + allOf: [ + { $ref: '#Entity' }, + { + properties: { + nhiType: { + type: 'string', + enum: [ + 'service_account', + 'credential', + 'secret', + 'oauth_app', + 'bot', + 'certificate', + 'api_key', + 'webhook', + 'ci_cd_identity', + ], + }, + isAi: { type: 'boolean' }, + aiConfidence: { + type: 'string', + enum: ['confirmed', 'high', 'medium', 'low'], + }, + aiPlatform: { type: 'string' }, + nhiOwnerStatus: { + type: 'string', + enum: ['assigned', 'unassigned', 'orphaned'], + }, + }, + required: [], + }, + ], + }; + + beforeAll(() => { + // Register the inline NHI schema with both the data-model singleton and + // the validator singleton used by toMatchDataModelSchema. The published + // @jupiterone/data-model 0.62.0 does not yet ship NHI (AIASM-15). + if (!dataModel.getSchema('NHI')) { + dataModel.IntegrationSchema.addSchema(NHI_JSON_SCHEMA); + } + getValidatorSync().addSchemas(NHI_JSON_SCHEMA); + }); + + test('passes for User + NHI multi-class entity (BDD 14.4)', () => { + const SVC_USER_METADATA = { + resourceName: 'ServiceUser', + _type: 'gh_service_user_match', + _class: ['User', 'NHI'], + schema: { + $id: '#gh_service_user_match', + description: 'GitHub service user — multi-class User + NHI', + allOf: [ + { $ref: '#User' }, + { $ref: '#NHI' }, + { + properties: { + _class: { + type: 'array', + items: [ + { const: 'User', type: 'string' }, + { const: 'NHI', type: 'string' }, + ], + additionalItems: false, + maxItems: 2, + minItems: 2, + }, + _type: { const: 'gh_service_user_match', type: 'string' }, + }, + type: 'object', + required: ['_class', '_type'], + }, + ], + }, + }; + + const entity: Entity = { + _key: 'gh:svc:account:match:1', + _type: 'gh_service_user_match', + _class: ['User', 'NHI'], + name: 'github-actions-bot', + // User-class required fields + displayName: 'GitHub Actions', + firstName: 'GitHub', + lastName: 'Actions', + email: ['bot@example.com'], + shortLoginId: ['gh-actions'], + username: ['gh-actions'], + // NHI-class metadata + nhiType: 'service_account', + isAi: false, + aiConfidence: 'low', + nhiOwnerStatus: 'assigned', + }; + + const result = toMatchDataModelSchema(entity, SVC_USER_METADATA); + expect(result.pass).toBe(true); + }); + }); }); describe('#toMatchDirectRelationshipSchema', () => {