Skip to main content

Consent Ledger Service — Testing Strategy

Version: 1.0 Status: Draft Owner: Trust & Safety / Quality Engineering Last Updated: 2026-04-21 Companion: APPLICATION_LOGIC · SECURITY_MODEL · FAILURE_MODES

1. Test pyramid

┌─────────────────────────┐
│ E2E (5–8 flows) │ Playwright + k6
├─────────────────────────┤
│ Contract (15+) │ Pact + gRPC reflection
├─────────────────────────┤
│ Integration (60+) │ Real PG + Redis + NATS (Testcontainers)
├─────────────────────────┤
│ Unit (250+) │ Domain, VOs, matchers, hash-chain
└─────────────────────────┘

Coverage targets:

  • Domain layer: ≥ 95% line, ≥ 90% branch
  • Application layer: ≥ 90% line, ≥ 85% branch
  • Adapters (Postgres, Redis, NATS): ≥ 80% line
  • Aggregate: ≥ 85% line / ≥ 80% branch (platform DoD)

2. Unit tests

Framework: Jest + ts-jest.

describe('ConsentRecord state machine', () => {
it('transitions PENDING -> OPT_IN on RecordConsent', () => { /* ... */ });
it('rejects OPT_OUT followed by OPT_IN as a no-op (records are immutable; new row instead)', () => { /* ... */ });
it('marks record EXPIRED when validUntil in the past', () => { /* ... */ });
it('refuses verificationMethod=DOUBLE_OPT_IN without confirmed DoubleOptin', () => { /* ... */ });
it('clears revoked_reason iff status != OPT_OUT (CHECK constraint)', () => { /* ... */ });
});

2.2 Hash-chain integrity

describe('HashChain', () => {
it('first row of partition uses prev_hash = 0x00…32', () => { /* ... */ });
it('record_hash = sha256(payload_hash || prev_hash)', () => { /* ... */ });
it('canonical serialisation is deterministic across key order, whitespace', () => {
const a = canonicalise({ b: 2, a: 1 });
const b = canonicalise({ a: 1, b: 2 });
expect(a).toBe(b);
});
it('verifier recomputes payload_hash with redacted_fields markers and still passes', () => { /* ... */ });
it('verifier returns CHAIN_BROKEN when middle row prev_hash mutated', () => { /* ... */ });
it('verifier handles signing_key_id rotation across consecutive rows', () => { /* ... */ });
});

2.3 STOP-keyword matcher (multi-language)

describe('StopKeywordMatcher', () => {
it.each([
['STOP', 'EN', true],
[' Stop ', 'EN', true],
['STOPALL', 'EN', true], // platform-default GLOBAL
['Hello, please STOP sending', 'EN', true], // first token after normalisation
['stop sign', 'EN', true],
['stopwatch is here', 'EN', false], // first token rule + 32-char window
['بند', 'DR', true],
['پایان', 'DR', true],
['بنديدل', 'PS', true],
['ودرول', 'PS', true],
['إلغاء', 'AR', true],
['وقف', 'AR', true],
['ﺳﻼم', 'AR', false], // Arabic greeting - must not match
// ZWJ/ZWNJ obfuscation
['S\u200CTOP', 'EN', true],
])('matches %s in %s -> %s', (body, lang, expected) => { /* ... */ });

it('200-message conformance set: 50 per language; ≥ 99% precision', () => { /* ... */ });
});

2.4 MSISDN validation (Msisdn VO)

describe('Msisdn VO', () => {
it.each([
['+93701234567', true], // Afghanistan canonical
['+447911123456', true], // foreign roaming OK at VO level
['0701234567', false], // missing country code
['+93 701 234 567', false], // whitespace not allowed in canonical
['+9370A234567', false],
['+9301234', false], // too short
['+9370123456789012345', false], // too long
])('validates %s -> %s', (input, ok) => { /* ... */ });
});

2.5 CheckConsent decision tree

describe('CheckConsent', () => {
it('returns ALLOWED_TENANT_RECORD when status=OPT_IN and validUntil > now', () => { /* ... */ });
it('returns BLOCKED_OPT_OUT when status=OPT_OUT', () => { /* ... */ });
it('returns BLOCKED_EXPIRED when validUntil <= now even if OPT_IN', () => { /* ... */ });
it('returns BLOCKED_NO_RECORD for MARKETING/OTP/EMERGENCY when no row exists', () => { /* ... */ });
it('returns ALLOWED_DEFAULT_TRANSACTIONAL for TRANSACTIONAL with no row', () => { /* ... */ });
it('returns BLOCKED_NATIONAL_DND when DND key present (regardless of tenant record)', () => { /* ... */ });
it('bypasses National DND when lane=P0_EMERGENCY', () => { /* ... */ });
it('returns CONSENT_UNKNOWN (fail-closed) when both Redis and PG unavailable', () => { /* ... */ });
});

2.6 Property-based tests (fast-check)

fc.assert(fc.asyncProperty(arbConsentRecord(), async (record) => {
// Round-trip: persist + read returns identical state (records are immutable)
await repo.save(record);
const loaded = await repo.findCurrent(record.tenantId, record.msisdnHash, record.scope);
return deepEqual(loaded, record);
}));

fc.assert(fc.asyncProperty(arbAuditChain(50), async (rows) => {
for (const r of rows) await repo.appendAudit(r);
const result = await verifier.verify(currentPartition);
return result.ok === true;
}));

fc.assert(fc.property(arbBody(), (body) => {
const normalised = normaliseForMatch(body);
// Idempotent normalisation
return normaliseForMatch(normalised) === normalised;
}));

3. Integration tests

Framework: Jest + Testcontainers (Postgres 16, Redis 7, NATS 2.10).

3.1 gRPC handler

describe('CheckConsent gRPC — integration', () => {
beforeAll(async () => {
pg = await startTestPostgres();
redis = await startTestRedis();
nats = await startTestNats();
await runMigrations(pg);
await seedDefaultKeywords(pg);
server = await startConsentLedgerService({ pg, redis, nats });
client = createGrpcClient('localhost:50071');
});

it('returns BLOCKED_NO_RECORD for MARKETING with no record', async () => { /* ... */ });
it('returns ALLOWED_TENANT_RECORD after RecordConsent', async () => { /* ... */ });
it('returns BLOCKED_NATIONAL_DND when DND row inserted', async () => { /* ... */ });
it('falls back to Postgres when Redis is killed mid-test', async () => { /* ... */ });
it('returns CONSENT_UNKNOWN when both Postgres and Redis are killed', async () => { /* ... */ });
});

3.2 STOP MO consumer end-to-end

describe('STOP MO consumer — integration', () => {
it('PER_TENANT policy: revokes only the tenant whose senderId received STOP', async () => { /* ... */ });
it('GLOBAL policy: revokes all tenants for the MSISDN', async () => { /* ... */ });
it('emits consent.revoked.v1 + consent.stop_mo.received.v1 + consent.ack_back.sent.v1', async () => { /* ... */ });
it('one-shot ack-back: subscriber's repeated STOP within 24h does not re-emit ack-back', async () => { /* ... */ });
it('orphan sender (no tenant): audit STOP_MO_RECEIVED with tenantId=null, no revoke, ack NATS', async () => { /* ... */ });
it('Pashto / Dari / Arabic STOP variants all resolve to the right tenant scope', async () => { /* ... */ });
});

3.3 Tenant isolation (RLS)

describe('Tenant RLS — integration', () => {
it('tenant A cannot read tenant B records via tenant-scoped session', async () => { /* ... */ });
it('tenant A cannot revoke tenant B records (PERMISSION_DENIED)', async () => { /* ... */ });
it('citizen JWT scoped to MSISDN cannot read another MSISDNs records', async () => { /* ... */ });
it('platform.regulator can read across tenants', async () => { /* ... */ });
});

3.4 Hash-chain end-to-end

describe('Hash chain — integration', () => {
it('verifier passes over a clean partition with 10,000 rows', async () => { /* ... */ });
it('verifier detects prev_hash mutation immediately', async () => { /* ... */ });
it('verifier passes after erasure redaction (chain preserved)', async () => { /* ... */ });
it('verifier handles signing_key_id rotation in the middle of a partition', async () => { /* ... */ });
});

3.5 Erasure end-to-end

describe('Erasure — integration', () => {
it('completes within 30 days; redacts records and audit; preserves chain; retains DND row', async () => { /* ... */ });
it('CheckConsent for original MSISDN after erasure returns BLOCKED_NO_RECORD/CONSENT_UNKNOWN', async () => { /* ... */ });
it('National-DND row remains intact after erasure', async () => { /* ... */ });
});

3.6 DND sync

describe('DND sync — integration', () => {
it('parses ATRA SFTP fixture and applies diff', async () => { /* ... */ });
it('rejects feed when PGP signature invalid', async () => { /* ... */ });
it('emits dnd.registry.synced.v1 with counts', async () => { /* ... */ });
it('sets ConsentDndStale-equivalent metric when last_seen > 24h', async () => { /* ... */ });
});

3.7 Bulk import

describe('Bulk import — integration', () => {
it('processes 10,000-row CSV in chunks of 500; reports per-row errors', async () => { /* ... */ });
it('audit-tags each accepted row with the CSV hash', async () => { /* ... */ });
it('rejects rows with bad MSISDN/scope/captured_at', async () => { /* ... */ });
});

4. Contract tests

4.1 gRPC (Pact + reflection)

  • compliance-engine is a consumer of CheckConsent. Pact verifies request/response shape and error mapping. Run on every PR for both services.
  • routing-engine and sms-firewall-service likewise.
  • tenant-sdk-gateway is a consumer of RecordConsent / RevokeConsent.

4.2 NATS event schemas

  • JSON Schema for every consent.* event in services/consent-ledger-service/contracts/events/.
  • Producer test: every published event validates against its schema.
  • Consumer side (in compliance-engine, notification-service, analytics-service, regulator-portal-service) imports the schema and validates on receive.

4.3 REST OpenAPI

  • Generated OpenAPI 3.1 served at /v1/consent/openapi.json.
  • Spec-driven contract tests in admin dashboard CI pipeline.

5. End-to-end tests

Framework: Playwright (citizen portal flows) + k6 (load).

5.1 Citizen erasure flow (Playwright)

test('citizen requests OTP, verifies, sees consent records, requests erasure, sees completion', async ({ page }) => {
await page.goto('https://citizen-portal.staging.ghasi.gov.af/consent');
await page.fill('#msisdn', '+93701234567');
await page.click('button:has-text("Request OTP")');
// OTP injected via test hook (real channel-router stubbed in staging)
const otp = await getTestOtp('+93701234567');
await page.fill('#otp', otp);
await page.click('button:has-text("Verify")');
await expect(page.locator('table.consent-records tbody tr')).toHaveCount(3);
await page.click('button:has-text("Erase All My Data")');
await page.click('button:has-text("Confirm")');
// Erasure is async; the page shows a tracking ID
const trackingId = await page.locator('[data-testid="erasure-id"]').innerText();
// Run processor manually for the test
await invokeAdminProcess(trackingId);
await page.reload();
await expect(page.locator('text=All your data has been erased')).toBeVisible();
});

5.2 Tenant double-opt-in (Playwright)

Validate tenant-side flow + simulate citizen click on the opt-in link.

5.3 STOP MO end-to-end

Inject MO via NATS publish; observe consent.records updated, ack-back enqueued, regulator-portal SIEM stream forwards event.

6. Load tests

Framework: ghz (gRPC) and k6 (REST).

6.1 CheckConsent throughput

ghz --proto src/proto/consent.proto \
--call ghasi.sms.consent.v1.ConsentLedgerService.CheckConsent \
--data-file ./test/load/check_consent_request.json \
--concurrency 200 --total 1000000 \
--rps 5000 \
--connections 50 \
--duration 5m \
localhost:50071

Pass criteria (CONS-US-004 §6):

  • Sustained 5,000 RPS for 10 minutes
  • P50 ≤ 2 ms · P95 ≤ 5 ms · P99 ≤ 20 ms
  • Error rate < 0.05%
  • No memory leak across the run

6.2 RecordConsent burst

  • 1,000 RPS sustained for 5 minutes; P95 ≤ 80 ms; PG pool not exhausted.

6.3 STOP MO consumer

  • 500 MO/s injected; consumer drains within budget; consent_stop_mo_consumer_lag_seconds returns to 0 within 60 s of stop.

7. Chaos tests

Run as a recurring chaos drill in staging (monthly):

ScenarioExpected behaviour
Kill Postgres primaryPatroni promotes Herat standby; CheckConsent latency briefly elevates; no data loss; reads continue from replica
Kill Redis clusterCheckConsent falls back to PG; latency rises but stays < 50 ms; cache fills back when Redis returns
Kill both Redis and PostgresCheckConsent returns CONSENT_UNKNOWN (fail-closed); compliance-engine treats as BLOCK; alert fires
Drop ATRA SFTP egressDND sync fails; ConsentDndStale fires after 24 h
NATS partition the consumerSTOP MO consumer lag rises; alert fires; on reconnect, lag drains
Vault outageExisting DEKs cached for 60 s; new MSISDN encryption fails after; CheckConsent unaffected

8. Security tests

  • Hash-chain tamper detection. Direct UPDATE on consent.audit is rejected by Postgres rule. Bypass attempt via SECURITY DEFINER without correct role: rejected. Bit-flip a payload_hash: verifier detects.
  • RLS bypass attempts. Tenant session attempting to read other tenant's records returns 0 rows; cross-tenant read attempts are tested for every endpoint × every role.
  • MSISDN-OTP brute force. Inject 100 OTP guesses; all but one rejected; rate-limit kicks in at 5/hour per MSISDN.
  • Citizen JWT replay after revocation. Token expires within 15 min; this is the mitigation; assert the revoked-citizen-cannot-read scenario.
  • CSV injection in bulk import. Supply a CSV with =cmd|... cells; verify they are stored as inert strings, not executed by any downstream.
  • Prompt injection in AI keyword suggester. Inputs include attempts to make the LLM emit malicious keywords; verify moderation filters drop them.
  • Residency check. Resolve all configured external endpoints; assert they map to Afghan IP space.

Tooling: ZAP baseline + API scan; Pact (already covered); gitleaks; osv-scanner; trivy.

9. CI / CD gating

StageRequired
PR openUnit + lint + type-check
PR ready-for-review+ Integration on Testcontainers + contract tests + secret scan
Merge to developAll above + 5-min load test smoke
Merge to masterAll above + chaos drill in staging + security scan + 14-day shadow window verification (for major changes)
Production deployManual gate after canary 5% / 25% / 100% with auto-rollback on SLO burn

10. Test data

  • Fixtures live in services/consent-ledger-service/test/fixtures/:
    • dnd_sample.csv (≈ 50,000 rows simulating ATRA feed)
    • bulk_import_sample.csv (≈ 10,000 rows mixed valid/invalid)
    • stop_mo_corpus_<lang>.json (50 per language; positive and negative)
    • audit_chain_50_rows_clean.sql and _tampered.sql
  • Synthetic MSISDNs use ranges reserved by ATRA for testing (+9379999XXXX).
  • No real subscriber MSISDN is ever committed to the repo.