Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Failing after 36s
CI / test-python-backend-compliance (push) Successful in 34s
CI / test-python-document-crawler (push) Successful in 26s
CI / test-python-dsms-gateway (push) Successful in 20s
- Company Profile: setCompanyProfile() und COMPLETE_STEP dispatch nach Backend-Load, damit Sidebar-Checkmarks nach Refresh erhalten bleiben - compliance-scope-engine.test.ts: Property-Namen anpassen (composite_score, risk_score, etc.) - dsfa/types.test.ts: 9 Sections (0-8), 7 required, 2 optional - api-client.test.ts: SDKApiClient-Klasse statt nicht-existierendem Singleton - types.test.ts: use-case-assessment statt use-case-workshop - vitest.config.ts: E2E-Verzeichnis aus Vitest excluden Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
373 lines
14 KiB
TypeScript
373 lines
14 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
||
import { complianceScopeEngine } from '../compliance-scope-engine'
|
||
|
||
// Helper: create an answer object (engine reads value + questionId)
|
||
function ans(questionId: string, value: unknown) {
|
||
return { questionId, value } as any
|
||
}
|
||
|
||
// Helper: create a minimal triggered-trigger (engine shape, not types shape)
|
||
function trigger(ruleId: string, minimumLevel: string, opts: Record<string, unknown> = {}) {
|
||
return { ruleId, minimumLevel, mandatoryDocuments: [], requiresDSFA: false, category: 'test', ...opts } as any
|
||
}
|
||
|
||
// ============================================================================
|
||
// calculateScores
|
||
// ============================================================================
|
||
|
||
describe('calculateScores', () => {
|
||
it('returns zero composite for empty answers', () => {
|
||
const scores = complianceScopeEngine.calculateScores([])
|
||
expect(scores.composite_score).toBe(0)
|
||
expect(scores.risk_score).toBe(0)
|
||
expect(scores.complexity_score).toBe(0)
|
||
expect(scores.assurance_need).toBe(0)
|
||
})
|
||
|
||
it('all-false boolean answers → zero composite', () => {
|
||
const scores = complianceScopeEngine.calculateScores([
|
||
ans('data_art9', false),
|
||
ans('data_minors', false),
|
||
ans('proc_ai_usage', false),
|
||
])
|
||
expect(scores.composite_score).toBe(0)
|
||
})
|
||
|
||
it('boolean true answer increases risk score', () => {
|
||
const scoresFalse = complianceScopeEngine.calculateScores([ans('data_art9', false)])
|
||
const scoresTrue = complianceScopeEngine.calculateScores([ans('data_art9', true)])
|
||
expect(scoresTrue.risk_score).toBeGreaterThan(scoresFalse.risk_score)
|
||
})
|
||
|
||
it('composite is weighted sum: risk×0.4 + complexity×0.3 + assurance×0.3', () => {
|
||
const scores = complianceScopeEngine.calculateScores([ans('data_art9', true)])
|
||
const expected = Math.round((scores.risk_score * 0.4 + scores.complexity_score * 0.3 + scores.assurance_need * 0.3) * 10) / 10
|
||
expect(scores.composite_score).toBe(expected)
|
||
})
|
||
|
||
it('numeric answer uses logarithmic normalization — higher value → higher score', () => {
|
||
const scoresLow = complianceScopeEngine.calculateScores([ans('data_volume', 10)])
|
||
const scoresHigh = complianceScopeEngine.calculateScores([ans('data_volume', 999)])
|
||
expect(scoresHigh.composite_score).toBeGreaterThan(scoresLow.composite_score)
|
||
})
|
||
|
||
it('array answer score proportional to count (max 1.0 at 5+)', () => {
|
||
const scores1 = complianceScopeEngine.calculateScores([ans('data_art9', ['gesundheit'])])
|
||
const scores5 = complianceScopeEngine.calculateScores([
|
||
ans('data_art9', ['gesundheit', 'biometrie', 'genetik', 'politisch', 'religion']),
|
||
])
|
||
expect(scores5.composite_score).toBeGreaterThan(scores1.composite_score)
|
||
})
|
||
|
||
it('empty array answer → zero contribution', () => {
|
||
const scoresEmpty = complianceScopeEngine.calculateScores([ans('data_art9', [])])
|
||
const scoresNone = complianceScopeEngine.calculateScores([])
|
||
expect(scoresEmpty.composite_score).toBe(scoresNone.composite_score)
|
||
})
|
||
})
|
||
|
||
// ============================================================================
|
||
// determineLevel
|
||
// ============================================================================
|
||
|
||
describe('determineLevel', () => {
|
||
it('composite ≤25 → L1', () => {
|
||
const level = complianceScopeEngine.determineLevel({ composite_score: 20 } as any, [])
|
||
expect(level).toBe('L1')
|
||
})
|
||
|
||
it('composite exactly 25 → L1', () => {
|
||
const level = complianceScopeEngine.determineLevel({ composite_score: 25 } as any, [])
|
||
expect(level).toBe('L1')
|
||
})
|
||
|
||
it('composite 26–50 → L2', () => {
|
||
const level = complianceScopeEngine.determineLevel({ composite_score: 40 } as any, [])
|
||
expect(level).toBe('L2')
|
||
})
|
||
|
||
it('composite exactly 50 → L2', () => {
|
||
const level = complianceScopeEngine.determineLevel({ composite_score: 50 } as any, [])
|
||
expect(level).toBe('L2')
|
||
})
|
||
|
||
it('composite 51–75 → L3', () => {
|
||
const level = complianceScopeEngine.determineLevel({ composite_score: 60 } as any, [])
|
||
expect(level).toBe('L3')
|
||
})
|
||
|
||
it('composite >75 → L4', () => {
|
||
const level = complianceScopeEngine.determineLevel({ composite_score: 80 } as any, [])
|
||
expect(level).toBe('L4')
|
||
})
|
||
|
||
it('hard trigger with minimumLevel L3 overrides score-based L1', () => {
|
||
const t = trigger('HT-A01', 'L3')
|
||
const level = complianceScopeEngine.determineLevel({ composite_score: 10 } as any, [t])
|
||
expect(level).toBe('L3')
|
||
})
|
||
|
||
it('hard trigger with minimumLevel L4 overrides score-based L2', () => {
|
||
const t = trigger('HT-F01', 'L4')
|
||
const level = complianceScopeEngine.determineLevel({ composite_score: 40 } as any, [t])
|
||
expect(level).toBe('L4')
|
||
})
|
||
|
||
it('level = max(score-level, trigger-level) — score wins when higher', () => {
|
||
const t = trigger('HT-B01', 'L2')
|
||
// score gives L3, trigger gives L2 → max = L3
|
||
const level = complianceScopeEngine.determineLevel({ composite_score: 60 } as any, [t])
|
||
expect(level).toBe('L3')
|
||
})
|
||
|
||
it('no triggers with zero composite → L1', () => {
|
||
const level = complianceScopeEngine.determineLevel({ composite_score: 0 } as any, [])
|
||
expect(level).toBe('L1')
|
||
})
|
||
})
|
||
|
||
// ============================================================================
|
||
// evaluateHardTriggers
|
||
// ============================================================================
|
||
|
||
describe('evaluateHardTriggers', () => {
|
||
it('empty answers → no triggers', () => {
|
||
const triggers = complianceScopeEngine.evaluateHardTriggers([])
|
||
expect(triggers).toHaveLength(0)
|
||
})
|
||
|
||
it('art9 health data answer triggers category art9 with minimumLevel L3', () => {
|
||
const triggers = complianceScopeEngine.evaluateHardTriggers([
|
||
ans('data_art9', ['gesundheit']),
|
||
])
|
||
const art9 = triggers.find((t: any) => t.category === 'art9')
|
||
expect(art9).toBeDefined()
|
||
expect((art9 as any).minimumLevel).toBe('L3')
|
||
})
|
||
|
||
it('minors answer sets requiresDSFA = true', () => {
|
||
const triggers = complianceScopeEngine.evaluateHardTriggers([
|
||
ans('data_minors', true),
|
||
])
|
||
const minorsTrigger = triggers.find((t: any) => t.category === 'vulnerable')
|
||
expect(minorsTrigger).toBeDefined()
|
||
expect((minorsTrigger as any).requiresDSFA).toBe(true)
|
||
})
|
||
|
||
it('AI scoring usage answer triggers minimumLevel L2 (adm category)', () => {
|
||
const triggers = complianceScopeEngine.evaluateHardTriggers([
|
||
ans('proc_ai_usage', ['scoring']),
|
||
])
|
||
const aiTrigger = triggers.find((t: any) => t.category === 'adm')
|
||
expect(aiTrigger).toBeDefined()
|
||
expect(['L2', 'L3', 'L4']).toContain((aiTrigger as any).minimumLevel)
|
||
})
|
||
|
||
it('multiple triggers all returned when multiple questions answered', () => {
|
||
const triggers = complianceScopeEngine.evaluateHardTriggers([
|
||
ans('data_art9', ['gesundheit']),
|
||
ans('data_minors', true),
|
||
])
|
||
expect(triggers.length).toBeGreaterThanOrEqual(2)
|
||
})
|
||
|
||
it('false answer for boolean trigger does not fire it', () => {
|
||
const triggersBefore = complianceScopeEngine.evaluateHardTriggers([])
|
||
const triggersWithFalse = complianceScopeEngine.evaluateHardTriggers([
|
||
ans('data_minors', false),
|
||
])
|
||
expect(triggersWithFalse.length).toBe(triggersBefore.length)
|
||
})
|
||
})
|
||
|
||
// ============================================================================
|
||
// evaluate — integration
|
||
// ============================================================================
|
||
|
||
describe('evaluate — integration', () => {
|
||
it('empty answers → L1 ScopeDecision with all required fields', () => {
|
||
const decision = complianceScopeEngine.evaluate([])
|
||
expect(decision.scores).toBeDefined()
|
||
expect(decision.triggeredHardTriggers).toBeDefined()
|
||
expect(decision.requiredDocuments).toBeDefined()
|
||
expect(decision.riskFlags).toBeDefined()
|
||
expect(decision.gaps).toBeDefined()
|
||
expect(decision.nextActions).toBeDefined()
|
||
expect(decision.reasoning).toBeDefined()
|
||
expect(decision.determinedLevel).toBe('L1')
|
||
expect(decision.evaluatedAt).toBeDefined()
|
||
})
|
||
|
||
it('art9 + minors answers → determinedLevel L3 or L4', () => {
|
||
const decision = complianceScopeEngine.evaluate([
|
||
ans('data_art9', ['gesundheit', 'biometrie']),
|
||
ans('data_minors', true),
|
||
])
|
||
expect(['L3', 'L4']).toContain(decision.determinedLevel)
|
||
})
|
||
|
||
it('triggeredHardTriggers populated on evaluate with art9 answer', () => {
|
||
const decision = complianceScopeEngine.evaluate([
|
||
ans('data_art9', ['gesundheit']),
|
||
])
|
||
expect(decision.triggeredHardTriggers.length).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('composite score non-negative', () => {
|
||
const decision = complianceScopeEngine.evaluate([ans('org_employee_count', '50-249')])
|
||
expect((decision.scores as any).composite_score).toBeGreaterThanOrEqual(0)
|
||
})
|
||
|
||
it('evaluate returns array types for collections', () => {
|
||
const decision = complianceScopeEngine.evaluate([])
|
||
expect(Array.isArray(decision.triggeredHardTriggers)).toBe(true)
|
||
expect(Array.isArray(decision.requiredDocuments)).toBe(true)
|
||
expect(Array.isArray(decision.riskFlags)).toBe(true)
|
||
expect(Array.isArray(decision.gaps)).toBe(true)
|
||
expect(Array.isArray(decision.nextActions)).toBe(true)
|
||
})
|
||
})
|
||
|
||
// ============================================================================
|
||
// buildDocumentScope
|
||
// ============================================================================
|
||
|
||
describe('buildDocumentScope', () => {
|
||
it('returns an array', () => {
|
||
const docs = complianceScopeEngine.buildDocumentScope('L1', [], [])
|
||
expect(Array.isArray(docs)).toBe(true)
|
||
})
|
||
|
||
it('each returned document has documentType, label, estimatedEffort', () => {
|
||
// Use a trigger with uppercase docType to test engine behaviour
|
||
const t = trigger('HT-A01', 'L3', {
|
||
category: 'art9',
|
||
mandatoryDocuments: ['VVT'],
|
||
})
|
||
const docs = complianceScopeEngine.buildDocumentScope('L3', [t], [])
|
||
docs.forEach((doc: any) => {
|
||
expect(doc.documentType).toBeDefined()
|
||
expect(doc.label).toBeDefined()
|
||
expect(typeof doc.estimatedEffort).toBe('number')
|
||
expect(doc.estimatedEffort).toBeGreaterThan(0)
|
||
})
|
||
})
|
||
|
||
it('mandatory documents have high priority', () => {
|
||
const t = trigger('HT-A01', 'L3', {
|
||
category: 'art9',
|
||
mandatoryDocuments: ['VVT'],
|
||
})
|
||
const docs = complianceScopeEngine.buildDocumentScope('L3', [t], [])
|
||
const mandatoryDocs = docs.filter((d: any) => d.requirement === 'mandatory')
|
||
mandatoryDocs.forEach((doc: any) => {
|
||
expect(doc.priority).toBe('high')
|
||
})
|
||
})
|
||
|
||
it('documents sorted: mandatory first', () => {
|
||
const decision = complianceScopeEngine.evaluate([
|
||
ans('data_art9', ['gesundheit']),
|
||
])
|
||
const docs = decision.requiredDocuments
|
||
if (docs.length > 1) {
|
||
// mandatory docs should appear before recommended ones
|
||
let seenNonMandatory = false
|
||
for (const doc of docs) {
|
||
if ((doc as any).requirement !== 'mandatory') seenNonMandatory = true
|
||
if (seenNonMandatory) {
|
||
expect((doc as any).requirement).not.toBe('mandatory')
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
it('sdkStepUrl present for known document types when available', () => {
|
||
const decision = complianceScopeEngine.evaluate([
|
||
ans('data_art9', ['gesundheit']),
|
||
])
|
||
// sdkStepUrl is optional — just verify it's a string when present
|
||
decision.requiredDocuments.forEach((doc: any) => {
|
||
if (doc.sdkStepUrl !== undefined) {
|
||
expect(typeof doc.sdkStepUrl).toBe('string')
|
||
}
|
||
})
|
||
})
|
||
})
|
||
|
||
// ============================================================================
|
||
// evaluateRiskFlags
|
||
// ============================================================================
|
||
|
||
describe('evaluateRiskFlags', () => {
|
||
it('no answers → empty flags array', () => {
|
||
const flags = complianceScopeEngine.evaluateRiskFlags([], 'L1')
|
||
expect(Array.isArray(flags)).toBe(true)
|
||
})
|
||
|
||
it('no encryption transit at L2+ → high risk flag (technical)', () => {
|
||
const flags = complianceScopeEngine.evaluateRiskFlags(
|
||
[ans('tech_encryption_transit', false)],
|
||
'L2',
|
||
)
|
||
const encFlag = flags.find((f: any) => f.category === 'technical')
|
||
expect(encFlag).toBeDefined()
|
||
expect((encFlag as any).severity).toBe('high')
|
||
})
|
||
|
||
it('no encryption rest at L2+ → high risk flag (technical)', () => {
|
||
const flags = complianceScopeEngine.evaluateRiskFlags(
|
||
[ans('tech_encryption_rest', false)],
|
||
'L2',
|
||
)
|
||
const encFlag = flags.find((f: any) => f.category === 'technical')
|
||
expect(encFlag).toBeDefined()
|
||
expect((encFlag as any).severity).toBe('high')
|
||
})
|
||
|
||
it('encryption flags not raised at L1', () => {
|
||
const flags = complianceScopeEngine.evaluateRiskFlags(
|
||
[ans('tech_encryption_transit', false)],
|
||
'L1',
|
||
)
|
||
const encFlag = flags.find((f: any) => f.message?.includes('Verschlüsselung'))
|
||
expect(encFlag).toBeUndefined()
|
||
})
|
||
|
||
it('third country transfer without guarantees → legal flag', () => {
|
||
const flags = complianceScopeEngine.evaluateRiskFlags(
|
||
[
|
||
ans('tech_third_country', true),
|
||
ans('tech_hosting_location', 'drittland'),
|
||
],
|
||
'L1',
|
||
)
|
||
const legalFlag = flags.find((f: any) => f.category === 'legal')
|
||
expect(legalFlag).toBeDefined()
|
||
})
|
||
|
||
it('≥250 employees without DPO → organizational flag', () => {
|
||
const flags = complianceScopeEngine.evaluateRiskFlags(
|
||
[
|
||
ans('org_has_dpo', false),
|
||
ans('org_employee_count', '250-999'),
|
||
],
|
||
'L2',
|
||
)
|
||
const orgFlag = flags.find((f: any) => f.category === 'organizational')
|
||
expect(orgFlag).toBeDefined()
|
||
})
|
||
|
||
it('DPO present with 250+ employees → no DPO flag', () => {
|
||
const flags = complianceScopeEngine.evaluateRiskFlags(
|
||
[
|
||
ans('org_has_dpo', true),
|
||
ans('org_employee_count', '250-999'),
|
||
],
|
||
'L2',
|
||
)
|
||
const orgFlag = flags.find((f: any) => f.category === 'organizational')
|
||
expect(orgFlag).toBeUndefined()
|
||
})
|
||
})
|