sender-id-registry-service — Testing Strategy
Version: 1.0 Status: Draft Owner: Trust & Safety + Platform Engineering Last Updated: 2026-04-21
1. Test Pyramid
┌────────────────────────┐
│ E2E (5–8 flows) │ Playwright + grpc clients
├────────────────────────┤
│ Load (3 scenarios) │ ghz / k6
├────────────────────────┤
│ Integration (60+) │ Real PG + Redis + NATS + MinIO + DNS test server
├────────────────────────┤
│ Contract (8 pacts) │ compliance-engine, routing-engine, firewall, channel-router, admin, customer, regulator, notification |
├────────────────────────┤
│ Unit (250+) │ State machine, restricted-pattern matcher, reputation formula, verification challenge generators
└────────────────────────┘
Coverage targets:
- Domain: ≥ 90% line / 85% branch
- Application: ≥ 85% line / 80% branch
- Infrastructure: ≥ 60% line
2. Unit Tests
Framework: Jest + ts-jest.
2.1 State machine
Every transition in DOMAIN_MODEL §4 is exercised:
describe('SenderIdStateMachine', () => {
it('SUBMITTED → KYC_REVIEW on reviewer claim', () => { ... });
it('rejects KYC_REVIEW → ACTIVE (must traverse VERIFIED)', () => { ... });
it('REVOKED is terminal — no transitions out', () => { ... });
it('reservedUntil = revokedAt + 365d on revoke', () => { ... });
it('rejects activate when currentVerificationLevel < requiredVerificationLevel', () => { ... });
it('rejects activate for restricted-pattern match without regulator letter', () => { ... });
it('SUSPENDED → ACTIVE requires remediationEvidenceUrl', () => { ... });
it('AUTO_SUSPEND when reputation crosses 30 from ACTIVE', () => { ... });
});
2.2 Restricted-pattern matcher
describe('RestrictedPatternMatcher', () => {
it('matches BANK-XYZ to BANK* pattern', () => { ... });
it('does not match GENBANK to BANK* (anchor on start)', () => { ... });
it('rejects non-re2-compatible regex at save time', () => { ... });
it('rejects ReDoS-vulnerable patterns at save time (10ms cap)', () => { ... });
it('selects strictest required level when multiple patterns match', () => { ... });
it('union of requiredDocTypes when multiple patterns match', () => { ... });
it('inactive patterns are not evaluated', () => { ... });
// Default seed matrix
it.each([
['BANK-XYZ', 'BANK'],
['GOV-MOJ', 'GOV'],
['MOJ-LEGAL', 'JUDICIAL'],
['AWCC-TX', 'MNO'],
['DAB-OTP', 'BANK'],
['MOPH-ALERT', 'HEALTH'],
['ATRA-NOTICE', 'GOV'],
['POLICE-001', 'EMERGENCY'],
])('value %s matches restricted category %s', (value, expected) => { ... });
});
2.3 Reputation formula
describe('ReputationFormula', () => {
it('clean sender (no hits) scores 100', () => {
expect(compute({ complianceHits: 0, complaints: 0, fraudHits: 0, deliveryRate: 1.0 })).toBe(100);
});
it('clamps at 0 for severe abuse', () => {
expect(compute({ complianceHits: 100, complaints: 50, fraudHits: 30, deliveryRate: 0.1 })).toBe(0);
});
it('weights fraud hits 5x heavier than compliance hits', () => { ... });
it('delivery rate of 0.5 deducts 15 points', () => { ... });
it('crosses 30 threshold triggers AUTO_SUSPEND band', () => { ... });
it('crosses 90 threshold qualifies for trusted-tenant fast path', () => { ... });
});
2.4 Verification challenge generators
describe('OtpChallenge', () => {
it('generates 6-digit cryptographically random OTP', () => { ... });
it('stores SHA-256 hash, never plaintext', () => { ... });
it('expires after 5 minutes', () => { ... });
it('rate-limits to 3 per registrant MSISDN per hour', () => { ... });
});
describe('DnsTxtChallenge', () => {
it('generates challenge as sha256(senderId || nonce) truncated 32', () => { ... });
it('challenge value is unique per verification attempt', () => { ... });
it('expects record at _ghasi-sid-verify.{domain}', () => { ... });
});
describe('NotarisedDualControl', () => {
it('rejects co-approve from same userId as primary', () => { ... });
it('records both reviewer ids in verification', () => { ... });
});
2.5 Sender-ID value normalisation
describe('SenderIdValue', () => {
it.each([
['ALPHA', 'BankXYZ', 'BANKXYZ'],
['ALPHA', ' bank-xyz ', 'BANK-XYZ'],
['SHORT', '7000', '7000'],
['SHORT', ' 70-00 ', '7000'],
['LONG', '+93701234567', '+93701234567'],
])('%s normalises %s to %s', (type, input, expected) => { ... });
it.each([
['ALPHA', 'TooLongSenderName123', 'SID_VALUE_INVALID'],
['SHORT', '12', 'SID_VALUE_INVALID'], // less than 4 digits
['LONG', '0093701234567', 'SID_VALUE_INVALID'], // not E.164
])('%s value %s rejected', (type, value, code) => { ... });
});
2.6 Property-based tests (fast-check)
// Verify is deterministic for stable state
fc.assert(fc.asyncProperty(arbSenderId(), arbTenantId(), async (sid, t) => {
const r1 = await verify(sid, t);
const r2 = await verify(sid, t);
return r1.status === r2.status && r1.currentLevel === r2.currentLevel;
}));
// State machine never violates invariants
fc.assert(fc.property(arbStateTransitionSequence(), (sequence) => {
let state = 'SUBMITTED';
for (const transition of sequence) {
state = applyOrReject(state, transition);
}
return validStates.includes(state);
}));
// Reputation always clamped to [0, 100]
fc.assert(fc.property(arbReputationInputs(), (inputs) => {
const score = compute(inputs);
return score >= 0 && score <= 100;
}));
3. Integration Tests
Framework: Jest + Testcontainers (real Postgres, Redis, NATS, MinIO, mock DNS server).
3.1 gRPC Verify integration
describe('Verify gRPC — integration', () => {
beforeAll(async () => {
db = await startTestPostgres();
redis = await startTestRedis();
await runMigrations(db);
await seedSampleSenderIds(db);
server = await startService({ db, redis });
client = createGrpcClient('localhost:50091');
});
it('returns ACTIVE for an activated sender-ID with matching tenant', async () => { ... });
it('returns TENANT_MISMATCH when tenant differs', async () => { ... });
it('returns SUSPENDED for suspended sender-IDs', async () => { ... });
it('returns REVOKED for revoked sender-IDs', async () => { ... });
it('returns UNKNOWN for unregistered sender-IDs', async () => { ... });
it('Verify P95 ≤ 5ms over 1000 sequential calls (cache warm)', async () => { ... });
it('cache invalidation on suspend within 30s (per US-SID-011 AC-3)', async () => {
await suspend(sid, reason);
await new Promise(r => setTimeout(r, 30_000));
const r = await client.verify({ senderId: sid.value, tenantId });
expect(r.status).toBe('SUSPENDED');
});
});
3.2 KYC submission and review
describe('Submission + KYC review flow', () => {
it('rejects restricted-name (BANK*) submission without regulator letter', async () => { ... });
it('encrypts KYC blob with per-tenant KEK and persists hash', async () => { ... });
it('reviewer APPROVE transitions state to KYC_APPROVED + emits event', async () => { ... });
it('reviewer REJECT records reason and emits event', async () => { ... });
it('reviewer REQUEST_INFO surfaces checklist to tenant', async () => { ... });
it('claim is atomic — second reviewer gets 409', async () => { ... });
it('SLA breach surfaces in queue listing after 5 business days', async () => { ... });
});
3.3 Verification flows
describe('OTP verification', () => {
it('issues OTP via mocked channel-router and stores hash', async () => { ... });
it('successful OTP raises currentVerificationLevel to OTP', async () => { ... });
it('3rd wrong attempt locks verification as FAILED', async () => { ... });
it('rate-limits 4th OTP issuance per registrant per hour', async () => { ... });
});
describe('DNS-TXT verification', () => {
it('passes when both DoT resolvers return matching TXT', async () => { ... });
it('FAIL when only one resolver returns the TXT (consensus required)', async () => { ... });
it('retries up to 5 times in 24h before manual fallback', async () => { ... });
});
describe('Notarised verification (dual control)', () => {
it('rejects co-approve from same reviewer (SID_DUAL_CONTROL_VIOLATION)', async () => { ... });
it('promotes to NOTARISED on second distinct reviewer', async () => { ... });
});
3.4 Reputation cron + intra-day deltas
describe('Reputation cron', () => {
it('full cycle for 1000 sender-IDs completes within 30s in test', async () => { ... });
it('crosses 30 boundary triggers auto-suspend', async () => { ... });
it('emits sender.id.reputation.changed.v1 on band transition', async () => { ... });
it('intra-day fraud event applies delta within 60s', async () => { ... });
it('distributed lock prevents double-execution across replicas', async () => { ... });
});
3.5 Public search
describe('Public search', () => {
it('returns only public projection (no KYC, no contact info)', async () => { ... });
it('rate-limits per IP at 100 RPS', async () => { ... });
it('tarpit response after rate-limit breach', async () => { ... });
it('emits abuse alert at >1000 distinct queries / hour', async () => { ... });
});
3.6 Regulator export
describe('Regulator export', () => {
it('on-demand export generates JSONL with expected schema', async () => { ... });
it('signature verification round-trip succeeds', async () => { ... });
it('SFTP transmission with mock-regulator-sftp succeeds and records ack', async () => { ... });
it('does NOT include any KYC content or contact info', async () => { ... });
it('cron retries on SFTP failure with exponential backoff', async () => { ... });
});
4. Contract Tests
Verify contracts with each downstream consumer using Pact:
| Contract | Consumer | Provider | Purpose |
|---|---|---|---|
compliance-engine ↔ sid (gRPC) | compliance-engine | sender-id-registry-service | Verify request/response shape; status enum exhaustive |
routing-engine ↔ sid (gRPC) | routing-engine | sid | same |
sms-firewall-service ↔ sid (gRPC) | firewall | sid | same |
channel-router-service ↔ sid (gRPC) | channel-router | sid | same |
admin-dashboard ↔ sid (REST) | admin-dashboard | sid | reviewer queue + decision endpoints |
customer-portal ↔ sid (REST) | customer-portal | sid | submission + verification endpoints |
regulator-portal-service ↔ sid (REST) | regulator-portal | sid | export download |
notification-service event consumer | notification-service | sid (event producer) | sender.id.* event schemas |
Contract tests run on every CI build; breaking changes require coordinated rollout (per SYNC_CONTRACT §7).
5. Load Tests
Framework: ghz for gRPC, k6 for REST.
5.1 Verify hot path
ghz --proto src/proto/sid.proto \
--call ghasi.sms.sid.v1.SenderIdRegistryService.Verify \
--data-file ./test/load/verify-payload.json \
--concurrency 200 \
--total 5000000 \
--rps 5000 \
--duration 10m \
--host localhost:50091 \
--insecure-skip-verify=false \
--cacert ./test/certs/ca.pem
Pass criteria:
- P95 latency < 5 ms (cache warm)
- P99 latency < 15 ms
- Error rate < 0.01%
- No memory leak over 10-minute run
- CPU per pod < 70% at 5000 RPS
5.2 Burst test
ghz --concurrency 500 --rps 15000 --duration 30s ...
Pass criteria: P99 latency < 30 ms during burst; recovery to steady-state P95 within 60 s.
5.3 Public search load
k6 run --vus 200 --duration 5m public-search.js
Pass criteria: Edge cache (mocked Cloudflare) absorbs ≥ 95% of requests; origin P95 < 250 ms; rate-limit triggers correctly per IP.
6. E2E Scenarios
Framework: Playwright + gRPC clients orchestrated via TS test harness.
Scenario 1 — Full happy path
1. Tenant submits BANK-XYZ via /v1/sender-ids
2. Tenant uploads regulator letter + notarised authority
3. Reviewer A claims, requests info (missing board resolution)
4. Tenant uploads board resolution
5. Reviewer A claims, approves KYC
6. Tenant initiates DNS-TXT verification on dab.gov.af
7. Tenant publishes TXT record (mocked DNS)
8. Tenant clicks Verify → DOMAIN_DNS level set
9. Tenant initiates notarised verification
10. Reviewer A primary-approves; Reviewer B co-approves → NOTARISED level set
11. Admin activates → state ACTIVE
12. compliance-engine.Verify returns status=ACTIVE, level=NOTARISED, hasDomainDns=true
13. Tenant sends a message; orchestrator → compliance → SENDER_ID rule → ALLOW
Scenario 2 — Restricted-name rejection
Tenant attempts BANK-FAKE without regulator letter → 422 SID_RESTRICTED_REQUIREMENTS_UNMET.
Scenario 3 — Auto-suspension via reputation
Sender-ID accumulates compliance hits + fraud events → reputation drops below 30 → auto-suspend → compliance-engine.Verify returns SUSPENDED → next message HOLDed.
Scenario 4 — Revoke and 12-month reservation
Severe fraud → admin revokes → re-registration of same value → 409 SID_VALUE_TAKEN with reservedUntil. After mock-clock fast-forward 365 days → re-registration succeeds.
Scenario 5 — Public search
Citizen searches BANK-XYZ → sees registrant org name + verification level + status. Searches non-registered BANK-EVIL → returns empty. Hammers endpoint > 100 RPS → tarpit + 429.
Scenario 6 — Regulator export round-trip
On-demand export → signed file in MinIO → mock-regulator-SFTP receives → ack returned → sender.id.regulator.exported.v1 published.
Scenario 7 — KYC tamper detection
Test setup deliberately corrupts a KYC blob's stored hash → nightly cron flags sid_kyc_doc_tamper_detected_total and emits alert.
Scenario 8 — Multi-region split-brain
Partition kbl ↔ mzr for 5 min; both regions accept submission of same value → on heal, conflict detected, alert fires, manual reconciliation flow exercised.
7. Security Tests
- Restricted-pattern enforcement: every default seed pattern + 50 evasion attempts (homoglyphs, separators, fuzzed letters) verified.
- Document tamper detection: corrupt SHA-256 → cron alert + reviewer warning.
- ReDoS: feed 100 known ReDoS patterns to
POST /v1/admin/restricted-patterns→ all rejected. - Role escalation: every endpoint × every role matrix → expected 200/403.
- Cross-tenant access: tenant A cannot read tenant B's sender-IDs / KYC docs (RLS verified).
- Audit log immutability: attempt UPDATE/DELETE → no rows modified.
- Dual-control bypass: same reviewer attempts both notarised approvals → blocked.
- Public-search scraping: simulated bot → tarpit + alert.
8. Coverage Targets
| Layer | Line | Branch |
|---|---|---|
| Domain (state machine, value objects, formulas) | ≥ 90% | ≥ 85% |
| Application (use cases, orchestration) | ≥ 85% | ≥ 80% |
| Infrastructure (PG repos, Redis adapters, NATS adapters, S3 adapters) | ≥ 60% | ≥ 50% |
| Overall | ≥ 80% | ≥ 75% |
Reported via pnpm test:cov (Jest + nyc/c8). Reports uploaded to Codecov; PRs blocked on coverage regression > 1% absolute.