Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/ngn-rails.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,24 @@ a verification attempt, which Linq rate-limits. `submitCustomerKyc` is
deliberately **not** retried — a retried identity submission is a second
verification attempt against that limit.

**Failures.** The provider documented these on 2026-10-14. `code` is forwarded
verbatim; branch on it, never on `error`.

| `code` | Provider status | wraith answers |
| --- | --- | --- |
| `invalid_nin_format` | 400 | 400 — also sent by wraith's own 11-digit check |
| `nin_already_used` | 409 | 409 |
| `nin_verification_failed` | 422 | 422 |
| `kyc_provider_error` | 502 | 502 |
| `kyc_internal_error` | 500 | 502 (a provider 5xx is ours to own) |

An **undocumented** code is passed through as sent, never mapped onto one of
these. A body with **no usable code** omits `code` entirely, and `error` says
only that the request failed and its status — never a cause nobody sent. Every
case, with the provider's body and wraith's answer side by side, is in
[`fixtures/linq/kyc-errors.json`](../fixtures/linq/kyc-errors.json); the clients
test against that same file.

### 3. Lock a rate

```
Expand Down
62 changes: 62 additions & 0 deletions fixtures/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Shared fixtures

Response shapes that more than one codebase has to agree on. The backend's tests
read these files directly. The Veil mobile and web apps read the **same** files,
so a client cannot test against a shape the backend does not produce.

If you change a shape here, the backend suite fails until wraith produces it, and
every client suite fails until that client handles it. That is the point.

## `linq/kyc-errors.json`

Failure responses for identity verification by NIN (W087).

| Field | Meaning |
| --- | --- |
| `documented` | The provider's documented KYC failure codes (2026-10-14) and their HTTP statuses. |
| `cases[].kind` | `documented`, `undocumented` (a code the provider has sent but not documented), or `no_code` (no usable code and no usable message). |
| `cases[].provider` | What the provider sends to wraith: `status`, plus `body` (JSON) or `rawBody` (verbatim text, for non-JSON bodies). |
| `cases[].veil` | What wraith answers on `POST /ngn/customers/kyc` for that provider response. **This is what clients should test against.** |

### What the clients can rely on

- **`code` is forwarded verbatim.** It is never renamed, and an unknown code is
never mapped onto a known one. A client must branch on `code`, and must have a
default branch for codes it does not recognise.
- **No `code` means nobody said why.** When the provider gives no usable code,
the `code` key is absent (not `null`, not `""`). `error` then says only that
the request failed and its status; it never names a cause. Show generic copy.
- **A provider 5xx arrives as 502**, with its code intact: `kyc_internal_error`
is sent as 500 by the provider and reaches clients as 502. Branch on `code`,
not on status, for anything code-specific.
- **`invalid_nin_format` also comes from wraith itself.** The route checks the
11-digit shape before spending a rate-limited verification attempt, and that
refusal carries the same code as the provider's. One branch handles both.
- **`error` is prose for a person.** Never match on it; it changes when the
provider rewords.

### Using it from a client repo

Pin to a commit, never to a branch, so the shape cannot change under you
silently, and bump the pin deliberately:

```sh
curl -fsSL -o test/fixtures/kyc-errors.json \
https://raw.githubusercontent.com/Miracle656/wraith/<commit-sha>/fixtures/linq/kyc-errors.json
```

Then, for each case, feed `veil.status` and `veil.body` to the client's error
handling and assert the copy branch it picks — and that every `no_code` and
`undocumented` case lands in the generic branch.

### Rules for editing

- **No NIN, real or realistic.** A NIN is personal data under the NDPA. A test
asserts the file contains no 11-digit run and no `nin` field.
- **Bodies must be ones the provider sends.** The codes and statuses come from
the provider's documentation. The `message` prose in the documented cases is
representative, not captured verbatim; replace it with the real text when a
response is captured, rather than adding fields nobody has seen sent.
- The documented code list is restated in
`src/__tests__/linq/kycErrorFixtures.test.ts`, so editing this file alone cannot
redefine what the provider documented.
146 changes: 146 additions & 0 deletions fixtures/linq/kyc-errors.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
{
"description": "Linq KYC failure responses for POST /b2b/customers/kyc, and what wraith answers for each on POST /ngn/customers/kyc. Shared by the backend tests and the Veil mobile and web clients; see fixtures/README.md.",
"documentedOn": "2026-10-14",
"documented": {
"invalid_nin_format": 400,
"nin_already_used": 409,
"nin_verification_failed": 422,
"kyc_provider_error": 502,
"kyc_internal_error": 500
},
"cases": [
{
"id": "invalid_nin_format",
"kind": "documented",
"provider": {
"status": 400,
"body": { "message": "The NIN provided is not in a valid format", "code": "invalid_nin_format" }
},
"veil": {
"status": 400,
"body": { "error": "The NIN provided is not in a valid format", "code": "invalid_nin_format" }
}
},
{
"id": "nin_already_used",
"kind": "documented",
"provider": {
"status": 409,
"body": {
"message": "This NIN has already been used to verify another customer",
"code": "nin_already_used",
"customerRef": "veil_fixture_existing_ref"
}
},
"veil": {
"status": 409,
"body": { "error": "This NIN has already been used to verify another customer", "code": "nin_already_used" }
}
},
{
"id": "nin_verification_failed",
"kind": "documented",
"provider": {
"status": 422,
"body": { "message": "NIN verification failed", "code": "nin_verification_failed" }
},
"veil": {
"status": 422,
"body": { "error": "NIN verification failed", "code": "nin_verification_failed" }
}
},
{
"id": "kyc_provider_error",
"kind": "documented",
"provider": {
"status": 502,
"body": { "message": "The identity provider returned an error", "code": "kyc_provider_error" }
},
"veil": {
"status": 502,
"body": { "error": "The identity provider returned an error", "code": "kyc_provider_error" }
}
},
{
"id": "kyc_internal_error",
"kind": "documented",
"provider": {
"status": 500,
"body": { "message": "An internal error occurred during KYC", "code": "kyc_internal_error" }
},
"veil": {
"status": 502,
"body": { "error": "An internal error occurred during KYC", "code": "kyc_internal_error" }
}
},
{
"id": "undocumented_code_with_message",
"kind": "undocumented",
"provider": {
"status": 422,
"body": { "message": "Name does not match NIN record", "code": "nin_name_mismatch" }
},
"veil": {
"status": 422,
"body": { "error": "Name does not match NIN record", "code": "nin_name_mismatch" }
}
},
{
"id": "undocumented_code_without_message",
"kind": "undocumented",
"provider": {
"status": 503,
"body": { "code": "kyc_queue_unavailable" }
},
"veil": {
"status": 502,
"body": { "error": "Linq returned an error without giving a reason (HTTP 503, kyc_queue_unavailable)", "code": "kyc_queue_unavailable" }
}
},
{
"id": "no_code_empty_object_5xx",
"kind": "no_code",
"provider": { "status": 500, "body": {} },
"veil": {
"status": 502,
"body": { "error": "Linq returned an error without giving a reason (HTTP 500)" }
}
},
{
"id": "no_code_unrelated_fields_4xx",
"kind": "no_code",
"provider": { "status": 422, "body": { "ok": false } },
"veil": {
"status": 422,
"body": { "error": "Linq rejected the request" }
}
},
{
"id": "no_code_blank_message_and_code",
"kind": "no_code",
"provider": { "status": 500, "body": { "message": " ", "code": "" } },
"veil": {
"status": 502,
"body": { "error": "Linq returned an error without giving a reason (HTTP 500)" }
}
},
{
"id": "no_code_empty_body",
"kind": "no_code",
"provider": { "status": 502, "rawBody": "" },
"veil": {
"status": 502,
"body": { "error": "Linq returned an error without giving a reason (HTTP 502)" }
}
},
{
"id": "no_code_html_gateway_page",
"kind": "no_code",
"provider": { "status": 502, "rawBody": "<html><body><h1>502 Bad Gateway</h1></body></html>" },
"veil": {
"status": 502,
"body": { "error": "Linq returned an error without giving a reason (HTTP 502)" }
}
}
]
}
49 changes: 49 additions & 0 deletions src/__tests__/fixtures/kycErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Typed access to `fixtures/linq/kyc-errors.json`, the KYC failure shapes the
* backend and both Veil clients test against.
*
* Read from disk rather than imported: the file lives outside `src/` on
* purpose, so the clients can consume it without reaching into backend test
* code, and `rootDir` would refuse an import from there.
*/
import { readFileSync } from "fs";
import { join } from "path";

export const KYC_ERRORS_PATH = join(__dirname, "../../../fixtures/linq/kyc-errors.json");

export type KycErrorKind = "documented" | "undocumented" | "no_code";

export interface KycErrorCase {
id: string;
kind: KycErrorKind;
/** What Linq sends. Exactly one of `body` (JSON) or `rawBody` (verbatim text). */
provider: { status: number; body?: unknown; rawBody?: string };
/** What wraith answers on `POST /ngn/customers/kyc` for that provider response. */
veil: { status: number; body: { error: string; code?: string } };
}

export interface KycErrorFixtures {
description: string;
documentedOn: string;
documented: Record<string, number>;
cases: KycErrorCase[];
}

export const kycErrorsRaw = readFileSync(KYC_ERRORS_PATH, "utf8");
export const kycErrors = JSON.parse(kycErrorsRaw) as KycErrorFixtures;

/** The text a provider response carries, as `fetch` would hand it over. */
export function providerText(c: KycErrorCase): string {
return c.provider.rawBody ?? JSON.stringify(c.provider.body);
}

/** A `fetch` stub answering with this case's provider response. */
export function providerResponse(c: KycErrorCase) {
const text = providerText(c);
return {
ok: c.provider.status >= 200 && c.provider.status < 300,
status: c.provider.status,
headers: new Headers(),
text: async () => text,
};
}
7 changes: 5 additions & 2 deletions src/__tests__/linq/httpClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,13 +266,16 @@ describe("non-JSON error bodies, the same on every rail", () => {
provider(reply(502, "<html><body><h1>502 Bad Gateway</h1></body></html>"));
const err = (await run().catch((e: unknown) => e)) as LinqError;
expect(err.status).toBe(502);
expect(err.message).toBe("Linq rejected the request");
// A gateway failure is not the request being rejected (W087).
expect(err.message).toBe("Linq returned an error without giving a reason (HTTP 502)");
expect(err.message).not.toContain("<");
});

it.each(RAILS)("%s refuses a plain-text body too long to be a message", async (_rail, run) => {
provider(reply(500, "x".repeat(201)));
await expect(run()).rejects.toMatchObject({ message: "Linq rejected the request" });
await expect(run()).rejects.toMatchObject({
message: "Linq returned an error without giving a reason (HTTP 500)",
});
});
});

Expand Down
Loading
Loading