From e0ffe092116127ed646d6e507f8a18c493c2873f Mon Sep 17 00:00:00 2001 From: munisp <155237317+munisp@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:04:17 -0400 Subject: [PATCH 1/4] feat(gateway): reverse phone lookup provider chain (hlr) with E.164 normalization and fail-closed handler --- services/gateway/phone_lookup.go | 445 +++++++++++++++++++++++++++++++ 1 file changed, 445 insertions(+) create mode 100644 services/gateway/phone_lookup.go diff --git a/services/gateway/phone_lookup.go b/services/gateway/phone_lookup.go new file mode 100644 index 0000000..d7a35b6 --- /dev/null +++ b/services/gateway/phone_lookup.go @@ -0,0 +1,445 @@ +// phone_lookup.go — BIS API Gateway +// Reverse phone lookup (Intelius reverse-phone analog), provider-agnostic and +// fail-closed: no synthetic subscriber data is ever returned. When no upstream +// provider is configured the endpoint answers 503 with an explicit +// "phone lookup not configured" message. +// +// Provider chain is built from PHONE_LOOKUP_PROVIDERS (comma-separated, +// ordered). Currently implemented providers: +// +// hlr — generic HLR-lookup HTTP API +// env: HLR_API_URL, HLR_API_KEY, HLR_TIMEOUT_MS (default 8000) +// +// Route: GET /v1/phone/{number} (same auth middleware chain as /v1/nin). +// Input is normalized to E.164 with default region NG. Non-Nigerian numbers +// are rejected unless PHONE_LOOKUP_ALLOW_INTERNATIONAL=true. +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// ─── Types ──────────────────────────────────────────────────────────────────── + +// PhoneRecord is the normalized result of a reverse phone lookup. +type PhoneRecord struct { + Number string `json:"number"` // as queried (post-trim) + E164 string `json:"e164"` // normalized E.164 + Carrier string `json:"carrier,omitempty"` // current carrier (post-porting, when known) + LineType string `json:"lineType"` // mobile | landline | voip | unknown + SubscriberName string `json:"subscriberName,omitempty"` // only when the provider returns it + Country string `json:"country,omitempty"` // ISO 3166-1 alpha-2 + Source string `json:"source"` // raw provider label + CheckedAt string `json:"checkedAt"` +} + +// PhoneProvider is the provider-agnostic reverse-lookup contract. +type PhoneProvider interface { + Lookup(ctx context.Context, msisdn string) (*PhoneRecord, error) + Name() string +} + +// ─── Circuit breaker ────────────────────────────────────────────────────────── +// The gateway has no existing breaker for outbound provider calls, so this is +// a real consecutive-failure breaker: after `threshold` consecutive failures +// the circuit opens for `cooldown`, then allows a single half-open probe. + +type phoneCircuitBreaker struct { + mu sync.Mutex + failures int + openUntil time.Time + halfOpen bool + threshold int + cooldown time.Duration +} + +func newPhoneCircuitBreaker(threshold int, cooldown time.Duration) *phoneCircuitBreaker { + if threshold <= 0 { + threshold = 3 + } + if cooldown <= 0 { + cooldown = 30 * time.Second + } + return &phoneCircuitBreaker{threshold: threshold, cooldown: cooldown} +} + +var errPhoneCircuitOpen = errors.New("provider circuit open") + +// allow reports whether a call may proceed. +func (b *phoneCircuitBreaker) allow() bool { + b.mu.Lock() + defer b.mu.Unlock() + if b.openUntil.IsZero() { + return true + } + if time.Now().Before(b.openUntil) { + return false + } + // Cooldown elapsed: permit exactly one half-open probe. + if b.halfOpen { + return false + } + b.halfOpen = true + return true +} + +// report records the outcome of a call that was permitted by allow(). +func (b *phoneCircuitBreaker) report(err error) { + b.mu.Lock() + defer b.mu.Unlock() + if err == nil { + b.failures = 0 + b.openUntil = time.Time{} + b.halfOpen = false + return + } + if b.halfOpen { + // Half-open probe failed: re-open for a full cooldown. + b.halfOpen = false + b.openUntil = time.Now().Add(b.cooldown) + b.failures = b.threshold + return + } + b.failures++ + if b.failures >= b.threshold { + b.openUntil = time.Now().Add(b.cooldown) + } +} + +// ─── HLR provider ───────────────────────────────────────────────────────────── + +// hlrProvider implements PhoneProvider against a generic HLR-lookup HTTP API. +// Request: GET {base}/lookup?msisdn={e164} with Bearer + X-API-Key auth. +// Response: strict JSON (unknown fields rejected), mapped onto PhoneRecord. +type hlrProvider struct { + baseURL string + apiKey string + client *http.Client + breaker *phoneCircuitBreaker +} + +func newHLRProvider(baseURL, apiKey string, timeout time.Duration) *hlrProvider { + if timeout <= 0 { + timeout = 8 * time.Second + } + return &hlrProvider{ + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: apiKey, + client: &http.Client{Timeout: timeout}, + breaker: newPhoneCircuitBreaker(3, 30*time.Second), + } +} + +func (p *hlrProvider) Name() string { return "hlr" } + +// hlrLookupResponse is the strict wire contract for the generic HLR API. +type hlrLookupResponse struct { + MSISDN string `json:"msisdn"` + E164 string `json:"e164"` + Carrier string `json:"carrier"` + LineType string `json:"line_type"` + SubscriberName string `json:"subscriber_name"` + Country string `json:"country"` +} + +func (p *hlrProvider) Lookup(ctx context.Context, msisdn string) (rec *PhoneRecord, err error) { + if !p.breaker.allow() { + return nil, fmt.Errorf("hlr provider skipped: %w", errPhoneCircuitOpen) + } + defer func() { p.breaker.report(err) }() + + endpoint := p.baseURL + "/lookup?msisdn=" + url.QueryEscape(msisdn) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("hlr request build: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+p.apiKey) + req.Header.Set("X-API-Key", p.apiKey) + + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("hlr http call: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return nil, fmt.Errorf("hlr upstream status %d", resp.StatusCode) + } + + var wire hlrLookupResponse + dec := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)) + dec.DisallowUnknownFields() + if err := dec.Decode(&wire); err != nil { + return nil, fmt.Errorf("hlr response decode: %w", err) + } + + e164 := wire.E164 + if e164 == "" { + e164 = wire.MSISDN + } + if e164 == "" { + return nil, errors.New("hlr response contained no normalized number") + } + + return &PhoneRecord{ + Number: msisdn, + E164: e164, + Carrier: wire.Carrier, + LineType: normalizeLineType(wire.LineType), + SubscriberName: wire.SubscriberName, + Country: strings.ToUpper(wire.Country), + Source: p.Name(), + CheckedAt: now(), + }, nil +} + +// normalizeLineType constrains provider line-type values to the public enum. +func normalizeLineType(v string) string { + switch strings.ToLower(strings.TrimSpace(v)) { + case "mobile", "landline", "voip": + return strings.ToLower(strings.TrimSpace(v)) + default: + return "unknown" + } +} + +// ─── Provider chain ─────────────────────────────────────────────────────────── + +var errPhoneNotConfigured = errors.New("phone lookup not configured") + +// phoneLookupChain walks providers in configured order until one answers. +type phoneLookupChain struct { + providers []PhoneProvider +} + +// buildPhoneChainFromEnv constructs the ordered provider chain from +// PHONE_LOOKUP_PROVIDERS. Unknown provider names are skipped with a warning; +// a provider missing its credentials is skipped (never stubbed). +func buildPhoneChainFromEnv() *phoneLookupChain { + chain := &phoneLookupChain{} + spec := strings.TrimSpace(envOr("PHONE_LOOKUP_PROVIDERS", "")) + if spec == "" { + return chain + } + for _, name := range strings.Split(spec, ",") { + switch strings.ToLower(strings.TrimSpace(name)) { + case "": + continue + case "hlr": + base := envOr("HLR_API_URL", "") + key := envOr("HLR_API_KEY", "") + if base == "" || key == "" { + log.Printf("[WARN] phone provider 'hlr' configured but HLR_API_URL/HLR_API_KEY missing — skipping") + continue + } + timeout := time.Duration(8000) * time.Millisecond + if ms := envOr("HLR_TIMEOUT_MS", ""); ms != "" { + if d, err := time.ParseDuration(ms + "ms"); err == nil && d > 0 { + timeout = d + } + } + chain.providers = append(chain.providers, newHLRProvider(base, key, timeout)) + default: + log.Printf("[WARN] unknown phone lookup provider %q in PHONE_LOOKUP_PROVIDERS — skipping", name) + } + } + return chain +} + +// Lookup tries each provider in order; the first successful record wins. +// All failures are aggregated and returned so the handler can fail closed. +func (c *phoneLookupChain) Lookup(ctx context.Context, msisdn string) (*PhoneRecord, error) { + if c == nil || len(c.providers) == 0 { + return nil, errPhoneNotConfigured + } + failures := make([]string, 0, len(c.providers)) + for _, p := range c.providers { + rec, err := p.Lookup(ctx, msisdn) + if err == nil && rec != nil { + return rec, nil + } + failures = append(failures, fmt.Sprintf("%s: %v", p.Name(), err)) + log.Printf("[WARN] phone lookup provider %s failed for %s: %v", p.Name(), maskMSISDN(msisdn), err) + } + return nil, fmt.Errorf("all phone lookup providers failed (%s)", strings.Join(failures, "; ")) +} + +// phoneChain is built once at startup; provider env is static at runtime. +var phoneChain = buildPhoneChainFromEnv() + +// ─── E.164 normalization (default region NG) ───────────────────────────────── +// Strict small normalizer for Nigerian +234 / 0-prefix formats plus generic +// E.164 passthrough when international lookups are explicitly enabled. + +var errInvalidPhoneNumber = errors.New("invalid phone number") + +// strip phone punctuation: spaces, dashes, dots, parentheses. +func cleanPhoneInput(raw string) string { + var b strings.Builder + b.Grow(len(raw)) + for _, r := range raw { + switch { + case r >= '0' && r <= '9', r == '+': + b.WriteRune(r) + case r == ' ', r == '-', r == '.', r == '(', r == ')': + // ignored punctuation + default: + b.WriteRune(r) // preserved so digit validation rejects it + } + } + return b.String() +} + +func isAllDigits(s string) bool { + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return len(s) > 0 +} + +// validE164Shape enforces ITU-T E.164: "+" followed by 7–15 digits, first +// digit non-zero. +func validE164Shape(s string) bool { + if len(s) < 8 || len(s) > 16 || !strings.HasPrefix(s, "+") { + return false + } + d := s[1:] + if d[0] == '0' || !isAllDigits(d) { + return false + } + return true +} + +// normalizePhoneNG normalizes raw input to E.164 with default region NG. +// Non-Nigerian numbers are rejected unless allowInternational is true. +func normalizePhoneNG(raw string, allowInternational bool) (string, error) { + cleaned := cleanPhoneInput(strings.TrimSpace(raw)) + if cleaned == "" { + return "", errInvalidPhoneNumber + } + + switch { + case strings.HasPrefix(cleaned, "+234"): + // Already international NG format. + return validateNGNumber("+234" + cleaned[4:]) + case strings.HasPrefix(cleaned, "234") && len(cleaned) == 13 && isAllDigits(cleaned): + // Country code without plus. + return validateNGNumber("+" + cleaned) + case strings.HasPrefix(cleaned, "0"): + // National format with trunk prefix: 0803… → +234803… + return validateNGNumber("+234" + cleaned[1:]) + case isAllDigits(cleaned) && len(cleaned) == 10 && cleaned[0] != '0': + // Bare national significant number (default region NG). + return validateNGNumber("+234" + cleaned) + case strings.HasPrefix(cleaned, "+"): + // Other international format. + if !allowInternational { + return "", fmt.Errorf("%w: non-Nigerian numbers require PHONE_LOOKUP_ALLOW_INTERNATIONAL=true", errInvalidPhoneNumber) + } + if !validE164Shape(cleaned) { + return "", errInvalidPhoneNumber + } + return cleaned, nil + default: + return "", errInvalidPhoneNumber + } +} + +// validateNGNumber enforces the Nigerian numbering plan shape: +// - mobile: 10-digit NSN starting 7, 8 or 9 (070…, 080…, 090… ranges) +// - landline: 8–9 digit NSN starting 1 or 2 (e.g. Lagos 01-XXXXXXX → 1463XXXX) +func validateNGNumber(e164 string) (string, error) { + nsn := strings.TrimPrefix(e164, "+234") + if !isAllDigits(nsn) { + return "", errInvalidPhoneNumber + } + switch { + case len(nsn) == 10 && (nsn[0] == '7' || nsn[0] == '8' || nsn[0] == '9'): + return "+234" + nsn, nil + case (len(nsn) == 8 || len(nsn) == 9) && (nsn[0] == '1' || nsn[0] == '2'): + return "+234" + nsn, nil + default: + return "", errInvalidPhoneNumber + } +} + +// maskMSISDN masks the middle digits of an E.164 number for safe logging: +// +2348031234567 → +2348*****67 +func maskMSISDN(e164 string) string { + if len(e164) <= 7 { + return "***" + } + head, tail := 5, 2 + return e164[:head] + strings.Repeat("*", len(e164)-head-tail) + e164[len(e164)-tail:] +} + +// ─── Handler ────────────────────────────────────────────────────────────────── + +// GET /v1/phone/{number} — reverse phone lookup. +func handlePhoneLookup(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "GET required") + return + } + + raw := strings.TrimPrefix(r.URL.Path, "/v1/phone/") + allowInternational := strings.EqualFold(envOr("PHONE_LOOKUP_ALLOW_INTERNATIONAL", ""), "true") + e164, err := normalizePhoneNG(raw, allowInternational) + if err != nil { + writeError(w, http.StatusBadRequest, "INVALID_PHONE_NUMBER", "Number must be a valid Nigerian phone number (e.g. 0803…, 234…, +234…)") + return + } + + if len(phoneChain.providers) == 0 { + // Fail closed: no provider configured → explicit 503, never synthetic data. + writeError(w, http.StatusServiceUnavailable, "PHONE_LOOKUP_NOT_CONFIGURED", "phone lookup not configured") + return + } + + // Redis cache (TTL: 6h — carrier/porting data changes more often than identity data). + cacheKey := "phone:" + e164 + if cached := cacheGet(r.Context(), cacheKey); cached != nil { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Cache", "HIT") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(cached) + return + } + + rec, err := phoneChain.Lookup(r.Context(), e164) + if err != nil { + log.Printf("[WARN] phone lookup %s failed: %v", maskMSISDN(e164), err) + writeError(w, http.StatusServiceUnavailable, "PHONE_PROVIDER_UNAVAILABLE", "No phone lookup provider returned a record; no result was synthesized.") + return + } + rec.Number = raw + + log.Printf("[INFO] phone lookup %s: source=%s lineType=%s carrier=%s", maskMSISDN(e164), rec.Source, rec.LineType, rec.Carrier) + + if data, err := json.Marshal(rec); err == nil { + cacheSet(r.Context(), cacheKey, data, 6*time.Hour) + } + + publishEvent("bis.gateway.phone_lookup", map[string]any{ + "phone": maskMSISDN(e164), + "source": rec.Source, + "lineType": rec.LineType, + "timestamp": now(), + }) + + writeJSON(w, http.StatusOK, rec) +} From 9d8cae96e66bf5eda8da5e08603562ccc3ff270d Mon Sep 17 00:00:00 2001 From: munisp <155237317+munisp@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:06:52 -0400 Subject: [PATCH 2/4] test: phone lookup Go tests (normalization matrix, failover, fail-closed, auth) + lookup.phone vitest --- server/lookup-phone.test.ts | 92 +++++++ services/gateway/phone_lookup_test.go | 353 ++++++++++++++++++++++++++ 2 files changed, 445 insertions(+) create mode 100644 server/lookup-phone.test.ts create mode 100644 services/gateway/phone_lookup_test.go diff --git a/server/lookup-phone.test.ts b/server/lookup-phone.test.ts new file mode 100644 index 0000000..6568c8e --- /dev/null +++ b/server/lookup-phone.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { appRouter } from "./routers"; +import type { TrpcContext } from "./_core/context"; + +type AuthenticatedUser = NonNullable; + +const savedFetch = globalThis.fetch; + +afterEach(() => { + Object.defineProperty(globalThis, "fetch", { value: savedFetch, writable: true, configurable: true }); +}); + +function createContext(user: AuthenticatedUser | null): TrpcContext { + return { + user, + req: { protocol: "https", headers: {} } as TrpcContext["req"], + res: { clearCookie: () => undefined } as unknown as TrpcContext["res"], + }; +} + +const authUser: AuthenticatedUser = { + id: 1, + openId: "phone-lookup-user", + email: "analyst@example.com", + name: "Analyst", + loginMethod: "manus", + role: "user", + createdAt: new Date(), + updatedAt: new Date(), + lastSignedIn: new Date(), +}; + +function stubGatewayFetch() { + const calls: Array<{ url: string; headers: Record }> = []; + const mock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ url: String(input), headers: (init?.headers ?? {}) as Record }); + return new Response( + JSON.stringify({ + number: "08031234567", + e164: "+2348031234567", + carrier: "MTN Nigeria", + lineType: "mobile", + country: "NG", + source: "hlr", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + vi.stubGlobal("fetch", mock); + return calls; +} + +describe("lookup.phone", () => { + it("proxies a valid number to the gateway /v1/phone path with the service credential", async () => { + const calls = stubGatewayFetch(); + const caller = appRouter.createCaller(createContext(authUser)); + + const result = await caller.lookup.phone({ number: "08031234567" }); + + expect(result).toMatchObject({ e164: "+2348031234567", lineType: "mobile", source: "hlr" }); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toMatch(/\/v1\/phone\/08031234567$/); + expect(calls[0]!.headers["X-BIS-Key"]).toBeTruthy(); + }); + + it("URL-encodes the number in the proxy path", async () => { + const calls = stubGatewayFetch(); + const caller = appRouter.createCaller(createContext(authUser)); + + await caller.lookup.phone({ number: "+234 803 123 4567" }); + + expect(calls[0]!.url).toMatch(/\/v1\/phone\/%2B234%20803%20123%204567$/); + }); + + it("rejects invalid numbers before touching the gateway", async () => { + const calls = stubGatewayFetch(); + const caller = appRouter.createCaller(createContext(authUser)); + + for (const number of ["", "12", "not-a-phone-number", "+2348031234567;DROP TABLE", "9".repeat(21)]) { + await expect(caller.lookup.phone({ number })).rejects.toMatchObject({ code: "BAD_REQUEST" }); + } + expect(calls).toHaveLength(0); + }); + + it("requires an authenticated user", async () => { + const calls = stubGatewayFetch(); + const caller = appRouter.createCaller(createContext(null)); + + await expect(caller.lookup.phone({ number: "08031234567" })).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(calls).toHaveLength(0); + }); +}); diff --git a/services/gateway/phone_lookup_test.go b/services/gateway/phone_lookup_test.go new file mode 100644 index 0000000..adf48f5 --- /dev/null +++ b/services/gateway/phone_lookup_test.go @@ -0,0 +1,353 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// ─── E.164 normalization (NG formats matrix) ────────────────────────────────── + +func TestNormalizePhoneNG_NigerianFormats(t *testing.T) { + cases := []struct { + name string + input string + want string + }{ + {"trunk zero mobile", "08031234567", "+2348031234567"}, + {"bare NSN", "8031234567", "+2348031234567"}, + {"country code no plus", "2348031234567", "+2348031234567"}, + {"full E164", "+2348031234567", "+2348031234567"}, + {"trunk zero with spaces", "0803 123 4567", "+2348031234567"}, + {"E164 with spaces", "+234 803 123 4567", "+2348031234567"}, + {"dashes and dots", "0803-123-4567", "+2348031234567"}, + {"parenthesized", "(0803) 123 4567", "+2348031234567"}, + {"landline lagos trunk", "014630000", "+23414630000"}, + {"surrounding whitespace", " +2349012345678 ", "+2349012345678"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := normalizePhoneNG(tc.input, false) + if err != nil { + t.Fatalf("normalizePhoneNG(%q) error: %v", tc.input, err) + } + if got != tc.want { + t.Errorf("normalizePhoneNG(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestNormalizePhoneNG_RejectsInvalid(t *testing.T) { + invalid := []string{ + "", // empty + "12345", // too short + "0803123456", // NG trunk but 9-digit NSN + "080312345678", // NG trunk but 11-digit NSN + "+23408031234567", // NSN must not start with 0 after country code + "002348031234567", // IDD prefix not supported + "abcdefghij", // non-numeric + "+234803123456", // 9-digit NSN with country code + "+234", // country code only + } + for _, input := range invalid { + if got, err := normalizePhoneNG(input, false); err == nil { + t.Errorf("normalizePhoneNG(%q) = %q, want error", input, got) + } + } +} + +func TestNormalizePhoneNG_InternationalGating(t *testing.T) { + // Non-NG number rejected without the env gate. + if got, err := normalizePhoneNG("+14155552671", false); err == nil { + t.Errorf("expected non-NG rejection, got %q", got) + } else if !strings.Contains(err.Error(), "PHONE_LOOKUP_ALLOW_INTERNATIONAL") { + t.Errorf("expected international-gate error message, got %v", err) + } + // Allowed when the gate is on. + got, err := normalizePhoneNG("+14155552671", true) + if err != nil || got != "+14155552671" { + t.Errorf("international allowed: got %q err=%v", got, err) + } + // Malformed international still rejected with the gate on. + for _, bad := range []string{"+0123456789", "+1", "+1234567890123456789"} { + if got, err := normalizePhoneNG(bad, true); err == nil { + t.Errorf("normalizePhoneNG(%q, international) = %q, want error", bad, got) + } + } +} + +func TestMaskMSISDN(t *testing.T) { + masked := maskMSISDN("+2348031234567") + if masked != "+2348*******67" { + t.Errorf("maskMSISDN = %q", masked) + } + if strings.Contains(masked, "0312") { + t.Error("mask leaked middle digits") + } + if maskMSISDN("+12345") != "***" { + t.Error("short numbers must be fully masked") + } +} + +// ─── Provider chain failover ────────────────────────────────────────────────── + +func newHLRTestServer(t *testing.T, status int, body string) (*httptest.Server, *int) { + t.Helper() + calls := new(int) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *calls++ + if r.Header.Get("Authorization") == "" && r.Header.Get("X-API-Key") == "" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv, calls +} + +const validHLRBody = `{"msisdn":"+2348031234567","e164":"+2348031234567","carrier":"MTN Nigeria","line_type":"mobile","subscriber_name":"A. Bello","country":"ng"}` + +func TestPhoneChainFailover_FirstProviderErrorsSecondAnswers(t *testing.T) { + failing, failCalls := newHLRTestServer(t, http.StatusBadGateway, `{"error":"upstream down"}`) + succeeding, okCalls := newHLRTestServer(t, http.StatusOK, validHLRBody) + + chain := &phoneLookupChain{providers: []PhoneProvider{ + newHLRProvider(failing.URL, "test-key-a", 2*time.Second), + newHLRProvider(succeeding.URL, "test-key-b", 2*time.Second), + }} + + rec, err := chain.Lookup(context.Background(), "+2348031234567") + if err != nil { + t.Fatalf("expected failover to succeed, got %v", err) + } + if *failCalls != 1 || *okCalls != 1 { + t.Errorf("expected 1 call to each provider, got failing=%d succeeding=%d", *failCalls, *okCalls) + } + if rec.E164 != "+2348031234567" || rec.Carrier != "MTN Nigeria" || rec.LineType != "mobile" || rec.Source != "hlr" || rec.Country != "NG" { + t.Errorf("unexpected record: %+v", rec) + } + if rec.SubscriberName != "A. Bello" { + t.Errorf("expected subscriber name passthrough, got %q", rec.SubscriberName) + } +} + +func TestPhoneChainFailover_MalformedJSONFallsThrough(t *testing.T) { + broken, _ := newHLRTestServer(t, http.StatusOK, `{"msisdn":12345`) // invalid JSON + strictBad, _ := newHLRTestServer(t, http.StatusOK, `{"msisdn":"+2348031234567","unknown_field":true}`) // unknown field + good, goodCalls := newHLRTestServer(t, http.StatusOK, validHLRBody) + + chain := &phoneLookupChain{providers: []PhoneProvider{ + newHLRProvider(broken.URL, "k", 2*time.Second), + newHLRProvider(strictBad.URL, "k", 2*time.Second), + newHLRProvider(good.URL, "k", 2*time.Second), + }} + + rec, err := chain.Lookup(context.Background(), "+2348031234567") + if err != nil { + t.Fatalf("expected fallthrough to valid provider, got %v", err) + } + if rec.Carrier != "MTN Nigeria" { + t.Errorf("unexpected record: %+v", rec) + } + if *goodCalls != 1 { + t.Errorf("expected final provider called once, got %d", *goodCalls) + } +} + +func TestPhoneChainAllProvidersFail(t *testing.T) { + failing, _ := newHLRTestServer(t, http.StatusInternalServerError, `{"error":"down"}`) + chain := &phoneLookupChain{providers: []PhoneProvider{newHLRProvider(failing.URL, "k", 2*time.Second)}} + + if _, err := chain.Lookup(context.Background(), "+2348031234567"); err == nil { + t.Fatal("expected error when all providers fail") + } +} + +func TestPhoneChainCircuitBreakerOpens(t *testing.T) { + failing, calls := newHLRTestServer(t, http.StatusInternalServerError, `{"error":"down"}`) + p := newHLRProvider(failing.URL, "k", 2*time.Second) + chain := &phoneLookupChain{providers: []PhoneProvider{p}} + + for i := 0; i < 3; i++ { + _, _ = chain.Lookup(context.Background(), "+2348031234567") + } + if *calls != 3 { + t.Fatalf("expected 3 upstream calls before breaker opens, got %d", *calls) + } + // Circuit is open: next lookup must not hit the upstream. + if _, err := chain.Lookup(context.Background(), "+2348031234567"); err == nil { + t.Fatal("expected error from open circuit") + } else if !strings.Contains(err.Error(), "circuit open") { + t.Fatalf("expected circuit-open error, got %v", err) + } + if *calls != 3 { + t.Errorf("breaker open: upstream received %d calls, want 3", *calls) + } +} + +// ─── Fail-closed handler behaviour ──────────────────────────────────────────── + +func withPhoneChain(t *testing.T, chain *phoneLookupChain) { + t.Helper() + prev := phoneChain + phoneChain = chain + t.Cleanup(func() { phoneChain = prev }) +} + +func TestPhoneLookupFailClosed_NoProvidersConfigured(t *testing.T) { + t.Setenv("PHONE_LOOKUP_PROVIDERS", "") + withPhoneChain(t, buildPhoneChainFromEnv()) + + req := httptest.NewRequest(http.MethodGet, "/v1/phone/08031234567", nil) + rr := httptest.NewRecorder() + handlePhoneLookup(rr, req) + + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", rr.Code) + } + var body GatewayError + if err := json.NewDecoder(rr.Body).Decode(&body); err != nil { + t.Fatalf("decode error body: %v", err) + } + if body.Message != "phone lookup not configured" { + t.Errorf("expected explicit 'phone lookup not configured' message, got %q", body.Message) + } +} + +func TestPhoneLookupFailClosed_ConfiguredButMissingCredentials(t *testing.T) { + t.Setenv("PHONE_LOOKUP_PROVIDERS", "hlr") + t.Setenv("HLR_API_URL", "") + t.Setenv("HLR_API_KEY", "") + withPhoneChain(t, buildPhoneChainFromEnv()) + + req := httptest.NewRequest(http.MethodGet, "/v1/phone/08031234567", nil) + rr := httptest.NewRecorder() + handlePhoneLookup(rr, req) + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503 when provider credentials missing, got %d", rr.Code) + } +} + +func TestPhoneLookupRejectsInvalidNumber(t *testing.T) { + good, _ := newHLRTestServer(t, http.StatusOK, validHLRBody) + withPhoneChain(t, &phoneLookupChain{providers: []PhoneProvider{newHLRProvider(good.URL, "k", 2*time.Second)}}) + + for _, path := range []string{"/v1/phone/123", "/v1/phone/notanumber", "/v1/phone/+14155552671"} { + rr := httptest.NewRecorder() + handlePhoneLookup(rr, httptest.NewRequest(http.MethodGet, path, nil)) + if rr.Code != http.StatusBadRequest { + t.Errorf("%s: expected 400, got %d", path, rr.Code) + } + } +} + +func TestPhoneLookupSuccessPath(t *testing.T) { + good, calls := newHLRTestServer(t, http.StatusOK, validHLRBody) + withPhoneChain(t, &phoneLookupChain{providers: []PhoneProvider{newHLRProvider(good.URL, "k", 2*time.Second)}}) + + rr := httptest.NewRecorder() + handlePhoneLookup(rr, httptest.NewRequest(http.MethodGet, "/v1/phone/0803-123-4567", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + var rec PhoneRecord + if err := json.NewDecoder(rr.Body).Decode(&rec); err != nil { + t.Fatalf("decode record: %v", err) + } + if rec.E164 != "+2348031234567" || rec.Number != "0803-123-4567" || rec.LineType != "mobile" { + t.Errorf("unexpected record: %+v", rec) + } + if *calls != 1 { + t.Errorf("expected exactly 1 upstream call, got %d", *calls) + } +} + +// ─── Auth enforcement on the route ──────────────────────────────────────────── + +func TestPhoneRouteRequiresAuth(t *testing.T) { + gatewayKey = "phone-route-test-key" + mux := http.NewServeMux() + mux.HandleFunc("/v1/phone/", authMiddleware(handlePhoneLookup)) + + // No key → 401. + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/phone/08031234567", nil)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated: expected 401, got %d", rr.Code) + } + + // Wrong key → 401. + rr = httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/phone/08031234567", nil) + req.Header.Set("X-BIS-Key", "wrong") + mux.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("wrong key: expected 401, got %d", rr.Code) + } + + // Valid key reaches the handler (fail-closed 503 with empty chain). + withPhoneChain(t, &phoneLookupChain{}) + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/v1/phone/08031234567", nil) + req.Header.Set("X-BIS-Key", "phone-route-test-key") + mux.ServeHTTP(rr, req) + if rr.Code == http.StatusUnauthorized { + t.Fatal("valid key must pass auth middleware") + } + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("authenticated with empty chain: expected 503, got %d", rr.Code) + } +} + +func TestBuildPhoneChainFromEnv(t *testing.T) { + t.Setenv("PHONE_LOOKUP_PROVIDERS", "") + if c := buildPhoneChainFromEnv(); len(c.providers) != 0 { + t.Errorf("empty spec: expected 0 providers, got %d", len(c.providers)) + } + + t.Setenv("PHONE_LOOKUP_PROVIDERS", "bogus,hlr") + t.Setenv("HLR_API_URL", "https://hlr.example.test") + t.Setenv("HLR_API_KEY", "k") + c := buildPhoneChainFromEnv() + if len(c.providers) != 1 || c.providers[0].Name() != "hlr" { + t.Errorf("expected single hlr provider (bogus skipped), got %v", c.providers) + } + if _, err := c.Lookup(context.Background(), "+2348031234567"); err == nil { + t.Error("expected unreachable-provider error") + } +} + +func TestPhoneCircuitBreakerHalfOpenRecovery(t *testing.T) { + failing, calls := newHLRTestServer(t, http.StatusInternalServerError, `{"error":"down"}`) + p := newHLRProvider(failing.URL, "k", time.Second) + p.breaker = newPhoneCircuitBreaker(2, 40*time.Millisecond) + + if _, err := p.Lookup(context.Background(), "+2348031234567"); err == nil { + t.Fatal("want error") + } + if _, err := p.Lookup(context.Background(), "+2348031234567"); err == nil { + t.Fatal("want error") + } + // Open now. + if _, err := p.Lookup(context.Background(), "+2348031234567"); !errors.Is(err, errPhoneCircuitOpen) && !strings.Contains(err.Error(), "circuit open") { + t.Fatalf("want circuit open, got %v", err) + } + if *calls != 2 { + t.Fatalf("upstream calls = %d, want 2", *calls) + } + // After cooldown a half-open probe is allowed through. + time.Sleep(60 * time.Millisecond) + _, _ = p.Lookup(context.Background(), "+2348031234567") + if *calls != 3 { + t.Fatalf("half-open probe must reach upstream, calls = %d", *calls) + } +} From 3de3069556a07f05f853cc774eabcef384ca6ca4 Mon Sep 17 00:00:00 2001 From: munisp <155237317+munisp@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:13:26 -0400 Subject: [PATCH 3/4] feat(gateway): register GET /v1/phone/ route with standard auth middleware --- services/gateway/main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/services/gateway/main.go b/services/gateway/main.go index 5ca9048..f76f9f4 100644 --- a/services/gateway/main.go +++ b/services/gateway/main.go @@ -1309,6 +1309,7 @@ func newRouter() http.Handler { mux.HandleFunc("/v1/nin/", protected(handleNINLookup)) mux.HandleFunc("/v1/bvn/", protected(handleBVNLookup)) mux.HandleFunc("/v1/cac/", protected(handleCACLookup)) + mux.HandleFunc("/v1/phone/", protected(handlePhoneLookup)) mux.HandleFunc("/v1/sanctions/", protected(handleSanctionsCheck)) mux.HandleFunc("/v1/pep/", protected(handlePEPCheck)) mux.HandleFunc("/v1/credit/", protected(handleCreditCheck)) From 9701f626347f9e5328168206fec13eca4cae4e48 Mon Sep 17 00:00:00 2001 From: munisp <155237317+munisp@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:58:34 -0400 Subject: [PATCH 4/4] fix(gateway): strip *url.Error (carries full MSISDN in request URL) from hlr transport errors; add MSISDN-leak regression tests --- services/gateway/phone_lookup.go | 7 ++++ services/gateway/phone_lookup_test.go | 47 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/services/gateway/phone_lookup.go b/services/gateway/phone_lookup.go index d7a35b6..4bfe738 100644 --- a/services/gateway/phone_lookup.go +++ b/services/gateway/phone_lookup.go @@ -169,6 +169,13 @@ func (p *hlrProvider) Lookup(ctx context.Context, msisdn string) (rec *PhoneReco resp, err := p.client.Do(req) if err != nil { + // *url.Error embeds the request URL, which carries the full MSISDN + // query parameter — strip it so chained/logged errors never leak the + // phone number. + var urlErr *url.Error + if errors.As(err, &urlErr) { + return nil, fmt.Errorf("hlr http call: %w", urlErr.Err) + } return nil, fmt.Errorf("hlr http call: %w", err) } defer resp.Body.Close() diff --git a/services/gateway/phone_lookup_test.go b/services/gateway/phone_lookup_test.go index adf48f5..cf60746 100644 --- a/services/gateway/phone_lookup_test.go +++ b/services/gateway/phone_lookup_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "log" "net/http" "net/http/httptest" "strings" @@ -351,3 +352,49 @@ func TestPhoneCircuitBreakerHalfOpenRecovery(t *testing.T) { t.Fatalf("half-open probe must reach upstream, calls = %d", *calls) } } + +func TestPhoneChainErrorDoesNotLeakMSISDN(t *testing.T) { + // Unroutable endpoint forces a *url.Error transport failure, which embeds + // the request URL (and therefore the msisdn query param) unless stripped. + p := newHLRProvider("http://127.0.0.1:1", "k", 500*time.Millisecond) + chain := &phoneLookupChain{providers: []PhoneProvider{p}} + + msisdn := "+2348031234567" + _, err := chain.Lookup(context.Background(), msisdn) + if err == nil { + t.Fatal("expected transport error") + } + msg := err.Error() + for _, leak := range []string{msisdn, "2348031234567", "%2B2348031234567", "msisdn="} { + if strings.Contains(msg, leak) { + t.Fatalf("chained error leaks MSISDN (%q) in: %s", leak, msg) + } + } +} + +func TestPhoneLookupFailureLogDoesNotLeakMSISDN(t *testing.T) { + // End-to-end through the handler: the WARN log line must carry only the + // masked number even when the provider error used to embed the URL. + prev := log.Writer() + defer log.SetOutput(prev) + var buf strings.Builder + log.SetOutput(&buf) + + p := newHLRProvider("http://127.0.0.1:1", "k", 500*time.Millisecond) + withPhoneChain(t, &phoneLookupChain{providers: []PhoneProvider{p}}) + + rr := httptest.NewRecorder() + handlePhoneLookup(rr, httptest.NewRequest(http.MethodGet, "/v1/phone/08031234567", nil)) + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", rr.Code) + } + logs := buf.String() + for _, leak := range []string{"+2348031234567", "2348031234567", "%2B234", "08031234567"} { + if strings.Contains(logs, leak) { + t.Fatalf("handler logs leak MSISDN (%q) in: %s", leak, logs) + } + } + if !strings.Contains(logs, maskMSISDN("+2348031234567")) { + t.Errorf("expected masked MSISDN in logs, got: %s", logs) + } +}