Files
breakpilot-compliance/admin-compliance/lib/sdk/__tests__/compliance-scope-engine.test.ts
Benjamin Admin 7f38df9d9c
Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 38s
CI/CD / test-python-backend-compliance (push) Successful in 39s
CI/CD / test-python-document-crawler (push) Successful in 27s
CI/CD / test-python-dsms-gateway (push) Successful in 24s
CI/CD / deploy-hetzner (push) Has been cancelled
feat(scope): Split HT-H01 B2B/B2C + register Verbraucherschutz document types + RAG ingestion
- Split HT-H01 into HT-H01a (B2C/Hybrid mit Verbraucherschutzpflichten) und
  HT-H01b (reiner B2B mit Basis-Pflichten). B2B-Webshops bekommen keine
  Widerrufsbelehrung/Preisangaben/Fernabsatz mehr.
- Add excludeWhen/requireWhen to HardTriggerRule for conditional trigger logic
- Register 6 neue ScopeDocumentType: widerrufsbelehrung, preisangaben,
  fernabsatz_info, streitbeilegung, produktsicherheit, ai_act_doku
- Full DOCUMENT_SCOPE_MATRIX L1-L4 for all new types
- Align HardTriggerRule interface with actual engine field names
- Add Phase H (Verbraucherschutz) to RAG ingestion script:
  10 deutsche Gesetze + 4 EU-Verordnungen + HLEG Ethics Guidelines
- Add scripts/rag-sources.md with license documentation
- 9 new tests for B2B/B2C trigger split, all 326 tests pass

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:03:49 +01:00

471 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 2650 → 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 5175 → 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()
})
})
// ============================================================================
// HT-H01a/b: B2B vs B2C Webshop Trigger Split
// ============================================================================
describe('HT-H01a/b — B2B vs B2C Webshop', () => {
it('B2C webshop triggers HT-H01a with Verbraucherschutz documents', () => {
const triggers = complianceScopeEngine.evaluateHardTriggers([
ans('prod_webshop', true),
ans('org_business_model', 'B2C'),
])
const h01a = triggers.find((t: any) => t.ruleId === 'HT-H01a')
const h01b = triggers.find((t: any) => t.ruleId === 'HT-H01b')
expect(h01a).toBeDefined()
expect(h01b).toBeUndefined()
expect(h01a!.mandatoryDocuments).toContain('WIDERRUFSBELEHRUNG')
expect(h01a!.mandatoryDocuments).toContain('PREISANGABEN')
expect(h01a!.mandatoryDocuments).toContain('FERNABSATZ_INFO')
expect(h01a!.mandatoryDocuments).toContain('STREITBEILEGUNG')
})
it('B2B webshop triggers HT-H01b without Verbraucherschutz documents', () => {
const triggers = complianceScopeEngine.evaluateHardTriggers([
ans('prod_webshop', true),
ans('org_business_model', 'B2B'),
])
const h01a = triggers.find((t: any) => t.ruleId === 'HT-H01a')
const h01b = triggers.find((t: any) => t.ruleId === 'HT-H01b')
expect(h01a).toBeUndefined()
expect(h01b).toBeDefined()
expect(h01b!.mandatoryDocuments).toContain('DSE')
expect(h01b!.mandatoryDocuments).toContain('AGB')
expect(h01b!.mandatoryDocuments).toContain('COOKIE_BANNER')
expect(h01b!.mandatoryDocuments).not.toContain('WIDERRUFSBELEHRUNG')
expect(h01b!.mandatoryDocuments).not.toContain('PREISANGABEN')
})
it('B2B_B2C (hybrid) webshop triggers HT-H01a (Verbraucherschutz applies)', () => {
const triggers = complianceScopeEngine.evaluateHardTriggers([
ans('prod_webshop', true),
ans('org_business_model', 'B2B_B2C'),
])
const h01a = triggers.find((t: any) => t.ruleId === 'HT-H01a')
const h01b = triggers.find((t: any) => t.ruleId === 'HT-H01b')
expect(h01a).toBeDefined()
expect(h01b).toBeUndefined()
})
it('no webshop → neither HT-H01a nor HT-H01b fires', () => {
const triggers = complianceScopeEngine.evaluateHardTriggers([
ans('prod_webshop', false),
ans('org_business_model', 'B2C'),
])
const h01a = triggers.find((t: any) => t.ruleId === 'HT-H01a')
const h01b = triggers.find((t: any) => t.ruleId === 'HT-H01b')
expect(h01a).toBeUndefined()
expect(h01b).toBeUndefined()
})
it('webshop without business_model answer → HT-H01a fires (excludeWhen not matched)', () => {
const triggers = complianceScopeEngine.evaluateHardTriggers([
ans('prod_webshop', true),
])
const h01a = triggers.find((t: any) => t.ruleId === 'HT-H01a')
const h01b = triggers.find((t: any) => t.ruleId === 'HT-H01b')
// excludeWhen B2B: not matched (undefined !== 'B2B') → fires
expect(h01a).toBeDefined()
// requireWhen B2B: not matched (undefined !== 'B2B') → does not fire
expect(h01b).toBeUndefined()
})
})
// ============================================================================
// excludeWhen / requireWhen Logic (unit)
// ============================================================================
describe('excludeWhen / requireWhen — generic logic', () => {
it('excludeWhen with array value excludes any matching value', () => {
// HT-H01a has excludeWhen: { questionId: 'org_business_model', value: 'B2B' }
// This test verifies the single-value case works (B2B excluded)
const triggers = complianceScopeEngine.evaluateHardTriggers([
ans('prod_webshop', true),
ans('org_business_model', 'B2B'),
])
const h01a = triggers.find((t: any) => t.ruleId === 'HT-H01a')
expect(h01a).toBeUndefined()
})
it('requireWhen with non-matching value prevents trigger', () => {
// HT-H01b has requireWhen: { questionId: 'org_business_model', value: 'B2B' }
const triggers = complianceScopeEngine.evaluateHardTriggers([
ans('prod_webshop', true),
ans('org_business_model', 'B2C'),
])
const h01b = triggers.find((t: any) => t.ruleId === 'HT-H01b')
expect(h01b).toBeUndefined()
})
})