Skip to main content

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 () => { ... });
});
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:

ContractConsumerProviderPurpose
compliance-engine ↔ sid (gRPC)compliance-enginesender-id-registry-serviceVerify request/response shape; status enum exhaustive
routing-engine ↔ sid (gRPC)routing-enginesidsame
sms-firewall-service ↔ sid (gRPC)firewallsidsame
channel-router-service ↔ sid (gRPC)channel-routersidsame
admin-dashboard ↔ sid (REST)admin-dashboardsidreviewer queue + decision endpoints
customer-portal ↔ sid (REST)customer-portalsidsubmission + verification endpoints
regulator-portal-service ↔ sid (REST)regulator-portalsidexport download
notification-service event consumernotification-servicesid (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.

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

LayerLineBranch
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.