You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
esignet-service embeds the ThunderID authorization engine (github.com/thunder-id/thunderid/pkg/thunderidengine) and supplies its own host providers / plugins (Actor, Authn, Role) plus executors (the engine's ConsentExecutor and custom ones such as MosipOtpExecutor) to deliver OIDC/OAuth sign-in. The engine is wired in esignet-service/cmd/esignet/main.go.
There is no automated way to validate that a deployed esignet-service instance — and the plugins running inside it — behaves correctly end to end:
Existing Go tests (6 files) are unit-level (internal/clientmgmt, internal/config, internal/log) and do not touch the engine-embedding surface or the plugins.
End-to-end coverage exists only as shell smoke scripts (scripts/oauth-smoke.sh, scripts/client-mgmt-smoke.sh) that must be run by hand against a locally started server, have no structured assertions, produce no report, and cannot be scheduled.
Nothing exercises a specific target environment (dev/QA/staging) on a schedule or produces a shareable pass/fail artifact for it.
As a result, plugin/flow regressions in a deployed environment (after a redeploy, a config change, or a pinned Thunder-fork bump) are only caught manually, if at all.
Purpose (Mandatory)
Build a black-box API integration test rig for esignet-service that:
Points at a real, running esignet-service via a configurable base URL (localhost for local runs, or a specific environment domain) — it behaves like an external OIDC/OAuth client / API test rig.
Calls the real esignet-service endpoints and lets the service exercise its own plugins and dependencies (Redis, Postgres, MOSIP IDA / SunbirdRC) — the rig injects nothing and fakes nothing.
Ships a small, self-contained OAuth/OIDC client (auth-code, PKCE, private_key_jwt client-assertion signing) built with the Go standard library and golang-jwt, replacing the ad-hoc bash/python signing in the smoke scripts. The ThunderID engine's own client/signing helpers live under its internal/ packages and are not importable from a separate module, so the rig does not depend on the engine (see Placement & dependency management).
Produces a custom HTML report capturing each request/response (with secrets redacted), status codes, latencies, assertions, and per-scenario pass/fail — plus a machine-readable JSON result for CI (JUnit XML is a planned follow-up, see Roadmap).
Is packaged as a container image and deployed as a Kubernetes CronJob so it runs on a schedule against a chosen environment and publishes the report.
Clarification on approach (Mandatory)
This is a black-box / API-rig design, not an in-process engine harness. Explicitly:
No fake HTTP servers, no miniredis, no DB test doubles, no in-process thunderidengine.New(...). The service under test is already deployed with its real dependencies.
The rig only needs network reachability to the target, valid test-client credentials, and a provisioned test identity in that environment.
Pre-Requisites (As Applicable)
A reachable, deployed esignet-service instance for the target environment; base URL configured via env var (e.g. TARGET_BASE_URL / ISSUER_URL). ISSUER_URL at the target must match the base URL the rig uses.
A test OAuth client provisioned in the target env: client_id, registered redirect_uri, and the private key / JWK used for client_assertion (private_key_jwt).
A test identity present in the target env for the provider it runs (AUTHN_PROVIDER = mock/catalog password user, or a MOSIP/Sunbird sandbox identity + OTP retrieval mechanism).
All plugin/provider inputs are supplied through configuration (env vars / mounted secrets), never hardcoded. In particular, for every provider the OIDC client_id and the subject individual id (the identity/UIN/VID the flow authenticates — e.g. the MOSIP individual id or the mock username) are passed via config, so the same rig binary runs against any environment and any provisioned identity by configuration alone. See Data Fields.
Deployment assets: container image build, a Kubernetes CronJob manifest (schedule + target-env selection via env/secret), and a location to publish/store the HTML report artifact.
Placement & dependency management (Mandatory)
The rig lives outside esignet-service as its own top-level Go module at esignet-api-test/, decoupled from the service so it can be built, versioned, and deployed as an independent CronJob image. It does not import esignet-service internals — it is a black-box HTTP client. (It is a sibling of the pre-existing top-level api-test/ directory, which is the legacy Java/biometric eSignet test rig and is unrelated.)
The rig has no dependency on the ThunderID engine. The engine's OAuth/OIDC client, client_assertion signing, and PKCE helpers all live under github.com/thunder-id/thunderid/internal/..., and Go forbids importing another module's internal/ packages from a separate module — so they cannot be reused by import. Instead the rig implements a small self-contained OAuth client with the standard library plus golang-jwt (the same primitives, ~one file each for PKCE and client-assertion signing).
Consequently there is no require/replace on thunderid and no engine pin to keep in sync across two go.mod files. The rig's go.mod has a single direct dependency (golang-jwt), which keeps builds fast and removes the pin-drift risk a shared engine dependency would introduce.
Container build: run go build in a multi-stage image (or in CI); with only public dependencies the module fetch needs no auth.
2. Goals (Mandatory)
A standalone, config-driven Go API test rig placed outsideesignet-service as its own top-level Go module at esignet-api-test/ that targets a base URL and runs entirely over real HTTP — no in-process engine, no fakes. See Placement & dependency management below.
A self-contained OAuth/OIDC client for the auth-code + PKCE + client_assertion (private_key_jwt) flow, built with the standard library and golang-jwt, replacing the ad-hoc bash/python signing in the smoke scripts. (The ThunderID engine's own helpers are internal/-only and not importable, so they are re-implemented, not reused — see Placement & dependency management.)
Scenario suites that call the real endpoints and validate plugin behavior end to end:
Well-known (both/.well-known/openid-configurationand the advertised jwks_uri / jwks.json), /oauth2/authorize, /flow/execute, GET /flow/meta, callback, /oauth2/token (auth-code + PKCE + client assertion), /oauth2/userinfo — and, when the discovery document advertises support, PAR (pushed_authorization_request_endpoint, RFC 9126; honouring require_pushed_authorization_requests) and DPoP-bound flows (dpop_signing_alg_values_supported, RFC 9449).
Step-wise flow execution with expected steps per scenario (no open-ended "loop until COMPLETE"). Each /flow/execute response is self-describing — flowStatus, the available data.actions[] (by ref), the required data.inputs[] (by identifier) — and the rig answers the advertised action/inputs rather than hardcoding action refs. But each scenario declares the steps it expects for the flow under test (e.g. the eSignet flow: login-method selection → credentials → consent → COMPLETE), and the run is asserted against that expectation: an unexpected extra step, a missing step, or an unexpected flowStatusfails the scenario instead of being silently driven through. A step may be pure action selection with no inputs — the deployed eSignet flow's first step offers acr_otp / acr_password / acr_bio / acr_kbi — and the scenario's expected step declares which offered action it selects (the auth factor under test); the rig never guesses among alternatives.
Per-step assertions on every /flow/execute response. Each step (authentication, OTP, consent) is asserted and recorded individually: HTTP 200, challengeToken present, no error and no data.fieldErrors, and well-formed actions[]/inputs[]; on COMPLETE the response must carry the assertion, which is then consumed by the callback. A step that progresses but returns an unexpected shape fails that step by name in the report, not just the scenario as a whole.
Flow metadata validation via the engine's GET /flow/meta endpoint (type=APP|OU, id, language, namespace): assert i18n (the languages list and key-value translations maps), design (theme and layout present), and application/client metadata (application.name, application.logoUrl; plus OU metadata where configured) — and that the data.meta component tree in /flow/execute responses references them consistently ({{t(...)}} i18n keys, {{meta(application.*)}} placeholders). This pins the contract the login UI consumes.
Consent handling: the flow may include a consent step (ConsentExecutor). When the target env has consent capture enabled, the consent step is one of the scenario's expected steps and is answered (auto-approve) and asserted like any other; the happy path does not assume credentials → COMPLETE directly.
Provider-specific positive flows delivered one per phase, driven by the target env's AUTHN_PROVIDER, with the OIDC client_id and the subject individual id supplied via config for every provider: mock/catalog password login first, then MOSIP OTP (send-OTP executor → KYC auth → exchange), then Sunbird KBI (see Roadmap).
Both a confidential client (private_key_jwt) and a public client (plain PKCE, no client assertion), mirroring oauth-smoke.sh.
Input-driven negatives (unknown client, wrong credential, reused code, mismatched PKCE, bogus code) — delivered after the positive flows are proven (see Roadmap), but fully independent of them at run time: every scenario provisions its own state end to end (own PKCE pair, own authorize/flow session), no scenario consumes another's output, so any subset — negatives without positives, one plugin without the rest — can run alone via profiles.
Client-management API (/client-mgmt/...) as covered today by client-mgmt-smoke.sh (see Roadmap).
Engine-surface sanity checks (implicit but first-class): the endpoints above are served by the embedded ThunderID engine itself — the esignet plugins only sit behind them. The discovery/JWKS scenario and several negatives (unknown client, reused code, mismatched PKCE, bogus code) involve no plugin at all, so they double as sanity checks of the engine's OIDC surface; a failure there after a pinned Thunder-fork bump is an expected, first-class regression signal pointing at the engine pin rather than the plugins. Engine internals remain out of scope (owned upstream, see Out of Scope).
Token & claim validation (not just presence): fetch jwks_uri and cryptographically verify the id_token signature, iss, aud, and exp; assert state round-trips and (when sent) nonce matches; and assert /oauth2/userinfo returns the expected claims for the requested scope (from config), not merely a sub.
Capture and report: record every request/response (headers with secrets redacted, body, status, latency) per step and generate a custom HTML report with per-scenario pass/fail and assertion detail; emit a machine-readable JSON result for CI (JUnit XML is a planned follow-up).
Packaging & scheduling: build a container image and provide a CronJob manifest that runs the rig on a schedule against a specified environment; persist/publish the report artifact (see Operational Considerations).
Profile-based runs and single-testcase selection. Scenario selection is two-dimensional: the provider mode (AUTHN_PROVIDER) gates which plugin flows apply, and a run profile (TEST_PROFILES, comma-combinable) selects the suite: smoke (preflight + discovery + one happy path), positive (all applicable happy paths), negative (input-driven negatives), plugin (provider-specific flows only), regression (everything applicable). Scenarios carry profile tags (a scenario may belong to several profiles) and are mutually independent, so any profile runs standalone — a scheduled smoke every hour and a nightly regression can share one image and differ only in config. Additionally, TEST_RUN (substring/regex over scenario names, like go test -run) runs a single scenario or subset, overriding profile selection; because every scenario embeds its own flow dependency as internal setup (via shared helpers, never shared state — e.g. the code-reuse negative performs its own full login first), a single testcase always executes with its complete flow chain and per-step assertions intact.
A preflight environment-readiness check that fails fast and distinctly ("environment not ready") when the target is unreachable or the configured client/identity does not resolve — so scheduled runs don't misreport setup problems as plugin regressions.
3. Out of Scope (Mandatory)
In-process / hermetic engine tests, miniredis, DB test doubles, and fake MOSIP/Sunbird HTTP servers — explicitly not this approach.
Fault-injection negatives that require forcing a real dependency to fail (e.g. simulating a MOSIP outage); only input-driven negatives are in scope.
Provisioning/seeding the target environment itself (deploying esignet-service, creating clients/identities) — assumed to exist or handled by a separate bootstrap step.
Load / performance testing (performance-test/) and UI/browser tests (ui-test/).
Testing the ThunderID engine's own internals (flow runtime edge cases, declarative store adapter, themes/layouts — owned upstream). The engine's public OIDC/HTTP surface is still exercised implicitly, since the rig's endpoints are engine handlers (see Goals).
Retiring the existing shell smoke scripts / Postman collection (may be deprecated later as a follow-up).
4. Acceptance Criteria (Mandatory)
Given a reachable target esignet-service base URL with a provisioned test client and identity, When the rig runs the full authorization-code + PKCE flow over real HTTP, Then valid ID and access tokens are issued, /oauth2/userinfo returns the expected claims, and the scenario is reported as pass.
Given tokens are issued, When the rig validates them, Then the id_token signature verifies against the target's jwks_uri, its iss/aud/exp are correct, state round-trips, and the userinfo claims match those expected for the requested scope — not merely that a token/sub is present.
Given a target env with consent capture enabled, When the flow reaches a consent step, Then the rig auto-approves it and the happy path still completes and is reported pass.
Given the target env's configured AUTHN_PROVIDER, When the rig runs, Then only the applicable plugin scenarios execute (mosip → OTP flow, sunbird → KBI, catalog/mock → password) and validate the corresponding plugin behavior through the live endpoints.
Given a configured run profile (TEST_PROFILES), When the rig runs, Then only the scenarios tagged for that profile execute (smoke, positive, negative, plugin, regression — combinable), and any profile runs standalone because no scenario depends on another scenario's state.
Given a single-testcase filter (TEST_RUN), When the rig runs, Then only the matching scenario(s) execute, and each still runs its complete flow chain (its dependencies are internal setup steps, asserted and recorded like any other step).
Given a multi-step flow (authentication, OTP, consent), When the rig executes /flow/execute through the scenario's expected steps, Thenevery step's response is individually asserted (HTTP 200, challengeToken, no error/fieldErrors, well-formed actions/inputs) and the COMPLETE response carries the assertion; a malformed intermediate step fails that named step in the report, and a step sequence that differs from the scenario's expected steps fails the scenario.
Given the discovery document, When the rig configures itself, Then endpoints, PKCE method, client-auth methods, and feature gates (PAR, DPoP) are taken from the well-known document, and PAR/DPoP scenarios execute only when advertised (pushed_authorization_request_endpoint, dpop_signing_alg_values_supported), honouring require_pushed_authorization_requests.
Given the flow under test offers multiple login-method actions (e.g. acr_otp / acr_password / acr_bio / acr_kbi), When the rig executes its expected steps, Then it selects the action declared by the scenario (the auth factor under test) — never a guessed or first-listed alternative.
GivenGET /flow/meta for the client under test, When the rig validates it, Then i18n (languages, key-value translations), design (theme, layout), and application/client metadata (application.name, application.logoUrl) are present and well-formed, and the data.meta component tree in /flow/execute references them consistently.
Given the target is unreachable or the configured client/identity does not resolve, When the preflight check runs, Then the run reports a distinct "environment not ready" outcome rather than a plugin-regression failure.
Given a completed run, When the rig finishes, Then it produces an HTML report containing, per scenario/step, the request and response (secrets redacted), HTTP status, latency, assertions, and an overall pass/fail summary, plus a machine-readable JSON result (JUnit XML is a planned follow-up).
Given the rig deployed as a CronJob against a chosen environment, When the schedule fires, Then it runs unattended and exits non-zero on failure. (Persisting/publishing the report artifact to durable storage is tracked under Operational Considerations / Roadmap, not the ephemeral emptyDir default.)
Business Rules
Given the rig authenticates as a client, When it builds client_assertion, Then it signs with the test client's registered key (client_id and key/kid from config) using its own golang-jwt-based signer, with aud = the target token endpoint.
Given tokens are issued, When validated, Then the OIDC iss equals the target env's configured issuer/base URL and the id_token signature verifies against the discovery jwks_uri.
Given an authorization request, When the callback/token round-trip completes, Then the returned state equals the value the rig sent (CSRF/round-trip check).
Given any report output, When it records requests/responses, Then secrets (client keys, tokens, OTPs, cookies) are redacted or masked — including any OTP extracted from a fetched mock-SMTP email body.
Exceptions & Error Handling
Given the target base URL is unreachable or returns 5xx, When the rig runs, Then it records a clear failure (endpoint, status, latency) and continues/reports rather than crashing.
Given invalid credentials or an invalid OTP, When the authn step runs, Then the service rejects it and the rig asserts the expected error and marks the negative scenario pass.
Given an unknown/unregistered client_id, When/oauth2/authorize is called, Then the request is rejected and asserted.
Given a mismatched PKCE verifier or a reused authorization code, When/oauth2/token is called, Then the token request is rejected and asserted.
Basic Flow (As Applicable)
Rig loads config (target base URL, client_id, redirect_uri, client key/kid, provider mode, subject individual id + credential) from env / mounted secrets.
Rig constructs its self-contained OAuth client (PKCE generation, client_assertion signing with the configured key).
For each scenario it issues real HTTP calls: discovery → /oauth2/authorize → step-wise /flow/execute execution (resume, then answer each of the scenario's expected steps — credentials, OTP, consent — asserting every response, ending in flowStatus=COMPLETE) → /oauth2/auth/callback → /oauth2/token → /oauth2/userinfo.
Each request/response is captured (redacted) with timing and assertion results.
Rig aggregates results into an HTML report + JSON and exits non-zero on failure (for CI/CronJob visibility).
Scenarios (As Applicable)
Happy-path login for the target provider (catalog/mock password, MOSIP OTP, or Sunbird KBI) → tokens issued → userinfo claims verified.
Client-management CRUD against /client-mgmt/....
Input-driven negatives: bad credentials/OTP, unknown client, invalid PKCE, auth-code reuse.
Data Fields (As Applicable)
No new user-facing data fields — this is a testing/infrastructure change. All rig inputs are supplied via env vars / mounted secrets (never hardcoded), so switching environment, client, or identity is configuration-only. For every provider, the OIDC client_id and the subject individual id are passed through config.
Everything the target publishes in /.well-known/openid-configuration is discovered at run time, not configured. The rig fetches the discovery document once per run and derives from it:
Derived at run time
Discovery field
Used for
Issuer
issuer
asserted as OIDC iss (expected value defaults to the target base URL)
enable/force the PAR scenario only when advertised
DPoP
dpop_signing_alg_values_supported
enable the DPoP scenario only when advertised
Grant/response types
grant_types_supported, response_types_supported
preflight sanity check for authorization_code
Extra configuration required (the full list — everything not in this table comes from well-known)
Only what cannot be discovered remains in config: the target address, the client's credentials, the test identities/data, run selection, and a few execution knobs.
Config input
Env var
Required
Notes
Target base URL
TARGET_BASE_URL
yes
esignet-service under test; may include a path prefix (e.g. https://<host>/v1/esignet) — all endpoint paths are appended to it; also the default expected iss (OIDC_ISSUER_URL only as an override)
OIDC client id
CLIENT_ID
yes
primary test client provisioned in the target env (all providers)
Redirect URI
REDIRECT_URI
yes
must be registered for the client
Client signing key + kid
CLIENT_KEY_PATH, CLIENT_KID
yes (confidential)
PEM + kid for client_assertion; not needed for public-client-only runs
substring/regex over scenario names (like go test -run); overrides profiles. Each scenario embeds its own flow dependency, so one testcase runs complete
the identity the flow authenticates (mock username, or MOSIP/Sunbird UIN/VID)
Credential
TEST_PASSWORD (mock)
yes (mock)
password for the mock/catalog flow
Test data set
TEST_DATA_PATH
no
JSON/YAML file (mounted via Secret) with multiple clients and multiple identities for smoke/negative variants — see below
OTP source
OTP_SOURCE
no (default static)
static | dynamic — an orthogonal input-resolution concern, deliberately NOT tied to any plugin/provider name: any flow whose steps request an otp input uses the configured source. static submits TEST_OTP; dynamic fetches the real OTP from the environment's mock-SMTP stream
Static OTP
TEST_OTP
no (default 111111)
fixed OTP for OTP_SOURCE=static (sandbox identities are configured to accept it)
Dynamic OTP stream
OTP_WS_URL
yes if dynamic
explicit WebSocket URL of the environment's mock-SMTP stream (commonly wss://smtp.<domain>/mocksmtp/websocket). Explicit config, not derived — the SMTP host can differ from the target host/domain, so it cannot be inferred from TARGET_BASE_URL
OTP recipient mailbox
OTP_RECIPIENT_EMAIL
yes if dynamic
mailbox the mock-SMTP stream is keyed by
Expected claims
EXPECTED_CLAIMS
no
claims (and optionally values) the userinfo/id_token response must contain for the requested scope; sanity-bounded by discovery claims_supported
Smoke and negative scenarios need more than one value per input, so alongside the single-value shorthands above, the optional test-data set (TEST_DATA_PATH) carries lists:
clients[] — multiple client entries with a role tag: the confidential client (key + kid), a public client (no assertion), and a deliberately unregistered client id for the unknown-client negative.
identities[] — multiple subject entries with an id-type tag — uin | vid | phone | username — plus per-identity credential/OTP expectation, and deliberately invalid entries for negatives.
Scenarios pick entries by role and id type: smoke uses the primary client + identity; plugin positives run each id type applicable to the provider (e.g. MOSIP OTP by UIN and by VID, phone where the env supports it); negatives use the unregistered client and invalid identities. The single-value env vars remain the shorthand for the primary entries, so simple runs need no data file.
Non-Functional Requirements (As Applicable)
Fully config-driven target: switching environments (localhost ↔ env domain) requires only configuration, no source changes.
Secrets supplied via env vars / Kubernetes secrets and redacted in all report output.
Runnable both locally (single command against localhost) and as a container/CronJob against an environment.
Self-contained OAuth client (no ThunderID engine dependency), so the rig builds and runs independently of the service's engine pin.
HTML report is self-contained (portable artifact) and the run emits a machine-readable JSON result for CI gating.
Operational Considerations (As Applicable)
Non-prod / sandbox targets. The rig performs real authentications and (for MOSIP) triggers real OTP sends and audit-log entries, so it is intended for dev/QA/sandbox environments.
Preflight environment-readiness check. Before running scenarios, verify the target is reachable and the configured client/identity resolve; report a setup failure distinctly ("environment not ready") so scheduled runs don't misattribute missing provisioning to a plugin regression.
Failure alerting. The CronJob exits non-zero (visible to Kubernetes) and emits a machine-readable result; wire an alert on failure (Slack/webhook/email, or a Prometheus pushgateway metric with pass-rate over time) so red runs are actually noticed.
Report persistence & publishing. The manifest's default emptyDir is ephemeral — the report dies with the pod. For real use, persist to a PVC or object store (S3/MinIO) or publish to a static location so the artifact survives. (Roadmap.)
Transient-failure handling. Against a live environment, distinguish transient network errors from assertion failures; use bounded in-rig retries for idempotent GETs rather than relying solely on Job backoffLimit restarts, to reduce false reds.
Secret hygiene. The client signing key is mounted from a Kubernetes Secret and should be rotated periodically; access to the mounted key and to report artifacts should be restricted (reports may echo redacted-but-sensitive material).
Test-identity residue. Each run creates sessions/consent records in the target env; confirm this is harmless for the sandbox or add a cleanup step.
CI & lint for the new module. As a separate go.mod, add a dedicated CI job (go build / go test / go vet) and a .golangci.yml mirroring the service's linters, so the rig is built on every change and doesn't rot. Pin the container image by tag/digest rather than :latest.
Definition of Done (Mandatory)
Delivery is strictly phased: the framework lands first (this issue); then Phase 2 delivers one plugin's positive flow together with the report so the whole rig is verified end to end; then the remaining plugins' positive flows follow one phase at a time; and only after all positive flows are proven do negatives and the remaining scope follow (see Roadmap).
Phase 1 — framework (this issue):
Code implemented (config-driven API test rig module with a self-contained OAuth client; no engine dependency)
Config loading & validation (target base URL, client, provider mode, identity — all via env/secrets)
Step-wise flow execution — the rig answers the actions/inputs each /flow/execute response advertises (no hardcoded action refs), with the expected steps declared per scenario and consent auto-approve support; no open-ended loop-until-COMPLETE
Request/response capture with secret redaction
Profile-based scenario selection (TEST_PROFILES: smoke/positive/negative/plugin/regression, combinable) with mutually independent scenarios
Discovery-driven configuration: endpoints, PKCE method, client-auth methods, and PAR/DPoP feature gates read from /.well-known/openid-configuration; config reduced to the non-discoverable minimum (target, client credentials, identity, profile)
Preflight environment-readiness check reports "environment not ready" distinctly from a plugin regression
Unit tests added (rig helpers: config, secret redaction, PKCE + client_assertion signing) and passing (go test ./...)
Container image built and Kubernetes CronJob manifest added
CI job + .golangci.yml for the new module
Documentation updated (how to run locally and how to deploy/schedule against an env)
Phase 2 — first plugin positive flow + report (verifies the framework end to end):
Mock/catalog full auth-code + PKCE + client_assertion happy path (authorize → step-wise flow execution → callback → token → userinfo) against a real running esignet-service, AUTHN_PROVIDER=mock
Confidential + public client happy paths both covered
Consent step auto-approved when the target env has consent capture enabled
Per-step assertions on every /flow/execute response (HTTP 200, challengeToken, no error/fieldErrors, well-formed actions/inputs; assertion present on COMPLETE), each step named in the report
Flow metadata validated via GET /flow/meta for the client under test: i18n (languages, translations), design (theme, layout), application/client metadata (application.name, application.logoUrl)
id_token validated against jwks_uri (signature + iss/aud/exp), state round-trip asserted, and userinfo claims checked against expected (config, EXPECTED_CLAIMS)
HTML report generation implemented and verified against this real flow (per-step request/response, redaction, latency, assertions); JSON result emitted; non-zero exit on failure
Phase 3 — MOSIP OTP positive flow: send-OTP executor → KYC auth → exchange, AUTHN_PROVIDER=mosip, OTP via static and dynamic (mock-SMTP) sources — the OTP source is plugin-agnostic config; run per id type from the test-data set (UIN and VID, phone where the env supports it); verified via the same report.
Phase 4 — Sunbird KBI positive flow:AUTHN_PROVIDER=sunbird, KBI answers from config; verified via the same report.
Phase 5+ — negatives & remaining scope (delivered after the positive flows; independently runnable via profiles): input-driven negatives, PAR and DPoP scenarios (gated on discovery advertising them), client-management CRUD, JUnit XML, report publishing, alerting — see Roadmap.
5. References (Mandatory)
Documentation (As Applicable)
esignet-service/README.md and .env.example — architecture, AUTHN_PROVIDER modes, configuration, and issuer/base-URL rules.
UI/UX Design Link (As Applicable)
Not applicable (HTML report layout to be defined during implementation).
Git Discussion Link (As Applicable)
Upstream engine (embedded by esignet-service, but its OAuth-client/signing/PKCE helpers are internal/-only and therefore not importable by this separate module): https://github.com/thunder-id/thunderid — pkg/thunderidengine. The rig re-implements the equivalent client primitives with the standard library + golang-jwt.
Existing smoke assets to be superseded/reused: scripts/oauth-smoke.sh, scripts/client-mgmt-smoke.sh, scripts/smoke-client-lib.sh, postman/embedder-positive-flow.json.
6. Roadmap / Phased Delivery
The delivery order is deliberately framework first, then one positive flow per plugin, then everything else — each phase must be green (verified via the report against a real environment) before the next starts:
Phase 2 — mock/catalog positive flow + report: the first end-to-end plugin scenario — with per-step /flow/execute assertions and flow metadata validation — plus the HTML/JSON report, verifying the whole rig (framework → real service → report artifact) works before further plugins are added.
Phase 3 — MOSIP OTP positive flow.
Phase 4 — Sunbird KBI positive flow.
Phase 5+ — negatives and remaining scope: input-driven negatives (bad credentials/OTP, unknown client, invalid PKCE, auth-code reuse), PAR + DPoP scenarios, client-management CRUD, JUnit XML, report publishing, alerting. Delivered after the positives, but runnable standalone via profiles.
Each plugin phase just adds a provider-scoped Scenario on the same framework, driven entirely by the config inputs above (notably the per-provider client_id and individual id):
MOSIP OTP flow (Phase 3) — send-OTP executor → KYC auth → exchange, gated on AUTHN_PROVIDER=mosip; individual id + OTP source from config. The MOSIP Authenticate plugin validates the OTP against real MOSIP IDA (credentials["otp"]), so the rig must obtain the actual OTP. The OTP source is a flow-input-resolution concern, deliberately not tied to any plugin name — whenever a flow step requests an otp input, the configured source supplies it, whichever provider owns the flow. Both sources are supported:
static — a fixed OTP (TEST_OTP); sandbox identities are configured to accept it.
dynamic — mirror the existing MOSIP Java rig (OTPListener + NotificationListener in mosip-functional-tests / the legacy api-test): a WebSocket client connects to the environment's mock-SMTP stream at the configured OTP_WS_URL (commonly wss://smtp.<domain>/mocksmtp/websocket, but the SMTP host can differ from the target host, so the URL is explicit config rather than derived), receives each captured email, regex-extracts the 6-digit code (\b(\d{6})\b), and keys it by recipient mailbox. The rig starts the listener before the send-OTP step, then retrieves the code (poll with timeout) and submits it in the auth step. Requires an env that runs mocksmtp (sandbox/QA); it cannot work against locked-down prod where OTP reaches real inboxes. Adds a WebSocket client dependency (e.g. github.com/coder/websocket).
Sunbird KBI flow (Phase 4) — knowledge-based identity, gated on AUTHN_PROVIDER=sunbird; individual id + KBI answers from config.
Input-driven negatives (Phase 5+) — bad credentials/OTP, unknown client, invalid PKCE, auth-code reuse — added only after all positive flows are proven, so a red negative can't be confused with an unproven framework. Driven from the test-data set's unregistered-client and invalid-identity entries (per id type: uin/vid/phone/username). Independent of the positive scenarios at run time (own sessions, no shared state) and selectable alone via TEST_PROFILES=negative.
PAR + DPoP scenarios (Phase 5+) — Pushed Authorization Requests (RFC 9126): push the authorization request to the advertised pushed_authorization_request_endpoint, then authorize via request_uri; honour require_pushed_authorization_requests when the env mandates it. DPoP (RFC 9449): send DPoP proof JWTs on token/userinfo and assert token binding for clients configured with dpopBoundAccessTokens. Both are gated on the discovery document advertising support, so the same rig runs against targets with or without these features.
Client-management CRUD (Phase 5+) — /client-mgmt/... parity with client-mgmt-smoke.sh.
JUnit XML output (Phase 5+) — in addition to JSON, for CI systems that ingest JUnit.
Report publishing & persistence (Phase 5+) — attach/publish the HTML artifact from CI and the CronJob (PVC or object-store sidecar) instead of the ephemeral emptyDir default.
Failure alerting & trend metrics (Phase 5+) — notify on red runs (Slack/webhook/email) and/or push a pass-rate metric to Prometheus for SLO tracking over time.
1. Problem Statement (Mandatory)
esignet-serviceembeds the ThunderID authorization engine (github.com/thunder-id/thunderid/pkg/thunderidengine) and supplies its own host providers / plugins (Actor, Authn, Role) plus executors (the engine'sConsentExecutorand custom ones such asMosipOtpExecutor) to deliver OIDC/OAuth sign-in. The engine is wired in esignet-service/cmd/esignet/main.go.There is no automated way to validate that a deployed esignet-service instance — and the plugins running inside it — behaves correctly end to end:
internal/clientmgmt,internal/config,internal/log) and do not touch the engine-embedding surface or the plugins.scripts/oauth-smoke.sh,scripts/client-mgmt-smoke.sh) that must be run by hand against a locally started server, have no structured assertions, produce no report, and cannot be scheduled.As a result, plugin/flow regressions in a deployed environment (after a redeploy, a config change, or a pinned Thunder-fork bump) are only caught manually, if at all.
Purpose (Mandatory)
Build a black-box API integration test rig for esignet-service that:
private_key_jwtclient-assertion signing) built with the Go standard library andgolang-jwt, replacing the ad-hoc bash/python signing in the smoke scripts. The ThunderID engine's own client/signing helpers live under itsinternal/packages and are not importable from a separate module, so the rig does not depend on the engine (see Placement & dependency management).Clarification on approach (Mandatory)
This is a black-box / API-rig design, not an in-process engine harness. Explicitly:
miniredis, no DB test doubles, no in-processthunderidengine.New(...). The service under test is already deployed with its real dependencies.Pre-Requisites (As Applicable)
TARGET_BASE_URL/ISSUER_URL).ISSUER_URLat the target must match the base URL the rig uses.client_id, registeredredirect_uri, and the private key / JWK used forclient_assertion(private_key_jwt).AUTHN_PROVIDER=mock/catalog password user, or a MOSIP/Sunbird sandbox identity + OTP retrieval mechanism).client_idand the subject individual id (the identity/UIN/VID the flow authenticates — e.g. the MOSIP individual id or the mock username) are passed via config, so the same rig binary runs against any environment and any provisioned identity by configuration alone. See Data Fields.Placement & dependency management (Mandatory)
esignet-serviceas its own top-level Go module atesignet-api-test/, decoupled from the service so it can be built, versioned, and deployed as an independent CronJob image. It does not importesignet-serviceinternals — it is a black-box HTTP client. (It is a sibling of the pre-existing top-levelapi-test/directory, which is the legacy Java/biometric eSignet test rig and is unrelated.)client_assertionsigning, and PKCE helpers all live undergithub.com/thunder-id/thunderid/internal/..., and Go forbids importing another module'sinternal/packages from a separate module — so they cannot be reused by import. Instead the rig implements a small self-contained OAuth client with the standard library plusgolang-jwt(the same primitives, ~one file each for PKCE and client-assertion signing).require/replaceon thunderid and no engine pin to keep in sync across twogo.modfiles. The rig'sgo.modhas a single direct dependency (golang-jwt), which keeps builds fast and removes the pin-drift risk a shared engine dependency would introduce.go buildin a multi-stage image (or in CI); with only public dependencies the module fetch needs no auth.2. Goals (Mandatory)
esignet-serviceas its own top-level Go module atesignet-api-test/that targets a base URL and runs entirely over real HTTP — no in-process engine, no fakes. See Placement & dependency management below.client_assertion(private_key_jwt) flow, built with the standard library andgolang-jwt, replacing the ad-hoc bash/python signing in the smoke scripts. (The ThunderID engine's own helpers areinternal/-only and not importable, so they are re-implemented, not reused — see Placement & dependency management.)/.well-known/openid-configurationand the advertisedjwks_uri/ jwks.json),/oauth2/authorize,/flow/execute,GET /flow/meta, callback,/oauth2/token(auth-code + PKCE + client assertion),/oauth2/userinfo— and, when the discovery document advertises support, PAR (pushed_authorization_request_endpoint, RFC 9126; honouringrequire_pushed_authorization_requests) and DPoP-bound flows (dpop_signing_alg_values_supported, RFC 9449)./flow/executeresponse is self-describing —flowStatus, the availabledata.actions[](byref), the requireddata.inputs[](byidentifier) — and the rig answers the advertised action/inputs rather than hardcoding action refs. But each scenario declares the steps it expects for the flow under test (e.g. the eSignet flow: login-method selection → credentials → consent →COMPLETE), and the run is asserted against that expectation: an unexpected extra step, a missing step, or an unexpectedflowStatusfails the scenario instead of being silently driven through. A step may be pure action selection with no inputs — the deployed eSignet flow's first step offersacr_otp/acr_password/acr_bio/acr_kbi— and the scenario's expected step declares which offered action it selects (the auth factor under test); the rig never guesses among alternatives./flow/executeresponse. Each step (authentication, OTP, consent) is asserted and recorded individually: HTTP 200,challengeTokenpresent, noerrorand nodata.fieldErrors, and well-formedactions[]/inputs[]; onCOMPLETEthe response must carry theassertion, which is then consumed by the callback. A step that progresses but returns an unexpected shape fails that step by name in the report, not just the scenario as a whole.GET /flow/metaendpoint (type=APP|OU,id,language,namespace): assert i18n (thelanguageslist and key-valuetranslationsmaps), design (themeandlayoutpresent), and application/client metadata (application.name,application.logoUrl; plus OU metadata where configured) — and that thedata.metacomponent tree in/flow/executeresponses references them consistently ({{t(...)}}i18n keys,{{meta(application.*)}}placeholders). This pins the contract the login UI consumes.ConsentExecutor). When the target env has consent capture enabled, the consent step is one of the scenario's expected steps and is answered (auto-approve) and asserted like any other; the happy path does not assumecredentials → COMPLETEdirectly.AUTHN_PROVIDER, with the OIDCclient_idand the subject individual id supplied via config for every provider: mock/catalog password login first, then MOSIP OTP (send-OTP executor → KYC auth → exchange), then Sunbird KBI (see Roadmap).oauth-smoke.sh./client-mgmt/...) as covered today byclient-mgmt-smoke.sh(see Roadmap).jwks_uriand cryptographically verify theid_tokensignature,iss,aud, andexp; assertstateround-trips and (when sent)noncematches; and assert/oauth2/userinforeturns the expected claims for the requested scope (from config), not merely asub.AUTHN_PROVIDER) gates which plugin flows apply, and a run profile (TEST_PROFILES, comma-combinable) selects the suite:smoke(preflight + discovery + one happy path),positive(all applicable happy paths),negative(input-driven negatives),plugin(provider-specific flows only),regression(everything applicable). Scenarios carry profile tags (a scenario may belong to several profiles) and are mutually independent, so any profile runs standalone — a scheduled smoke every hour and a nightly regression can share one image and differ only in config. Additionally,TEST_RUN(substring/regex over scenario names, likego test -run) runs a single scenario or subset, overriding profile selection; because every scenario embeds its own flow dependency as internal setup (via shared helpers, never shared state — e.g. the code-reuse negative performs its own full login first), a single testcase always executes with its complete flow chain and per-step assertions intact.3. Out of Scope (Mandatory)
miniredis, DB test doubles, and fake MOSIP/Sunbird HTTP servers — explicitly not this approach.performance-test/) and UI/browser tests (ui-test/).4. Acceptance Criteria (Mandatory)
/oauth2/userinforeturns the expected claims, and the scenario is reported as pass.id_tokensignature verifies against the target'sjwks_uri, itsiss/aud/expare correct,stateround-trips, and the userinfo claims match those expected for the requested scope — not merely that a token/subis present.AUTHN_PROVIDER, When the rig runs, Then only the applicable plugin scenarios execute (mosip → OTP flow, sunbird → KBI, catalog/mock → password) and validate the corresponding plugin behavior through the live endpoints.TEST_PROFILES), When the rig runs, Then only the scenarios tagged for that profile execute (smoke,positive,negative,plugin,regression— combinable), and any profile runs standalone because no scenario depends on another scenario's state.TEST_RUN), When the rig runs, Then only the matching scenario(s) execute, and each still runs its complete flow chain (its dependencies are internal setup steps, asserted and recorded like any other step)./flow/executethrough the scenario's expected steps, Then every step's response is individually asserted (HTTP 200,challengeToken, noerror/fieldErrors, well-formedactions/inputs) and theCOMPLETEresponse carries theassertion; a malformed intermediate step fails that named step in the report, and a step sequence that differs from the scenario's expected steps fails the scenario.pushed_authorization_request_endpoint,dpop_signing_alg_values_supported), honouringrequire_pushed_authorization_requests.acr_otp/acr_password/acr_bio/acr_kbi), When the rig executes its expected steps, Then it selects the action declared by the scenario (the auth factor under test) — never a guessed or first-listed alternative.GET /flow/metafor the client under test, When the rig validates it, Then i18n (languages, key-valuetranslations), design (theme,layout), and application/client metadata (application.name,application.logoUrl) are present and well-formed, and thedata.metacomponent tree in/flow/executereferences them consistently.emptyDirdefault.)Business Rules
client_assertion, Then it signs with the test client's registered key (client_idand key/kidfrom config) using its owngolang-jwt-based signer, withaud= the target token endpoint.issequals the target env's configured issuer/base URL and theid_tokensignature verifies against the discoveryjwks_uri.stateequals the value the rig sent (CSRF/round-trip check).Exceptions & Error Handling
client_id, When/oauth2/authorizeis called, Then the request is rejected and asserted./oauth2/tokenis called, Then the token request is rejected and asserted.Basic Flow (As Applicable)
client_id, redirect_uri, client key/kid, provider mode, subject individual id + credential) from env / mounted secrets.client_assertionsigning with the configured key)./oauth2/authorize→ step-wise/flow/executeexecution (resume, then answer each of the scenario's expected steps — credentials, OTP, consent — asserting every response, ending inflowStatus=COMPLETE) →/oauth2/auth/callback→/oauth2/token→/oauth2/userinfo.Scenarios (As Applicable)
/client-mgmt/....Data Fields (As Applicable)
No new user-facing data fields — this is a testing/infrastructure change. All rig inputs are supplied via env vars / mounted secrets (never hardcoded), so switching environment, client, or identity is configuration-only. For every provider, the OIDC
client_idand the subject individual id are passed through config.Discovery-driven configuration (minimal config surface)
Everything the target publishes in
/.well-known/openid-configurationis discovered at run time, not configured. The rig fetches the discovery document once per run and derives from it:issueriss(expected value defaults to the target base URL)authorization_endpoint,token_endpoint,userinfo_endpointjwks_uricode_challenge_methods_supportedS256; fail if absenttoken_endpoint_auth_methods_supportedprivate_key_jwt(confidential) and/ornone(public) pathsscopes_supportedSCOPEis advertised before requesting itclaims_supportedEXPECTED_CLAIMSpushed_authorization_request_endpoint,require_pushed_authorization_requestsdpop_signing_alg_values_supportedgrant_types_supported,response_types_supportedauthorization_codeExtra configuration required (the full list — everything not in this table comes from well-known)
Only what cannot be discovered remains in config: the target address, the client's credentials, the test identities/data, run selection, and a few execution knobs.
TARGET_BASE_URLhttps://<host>/v1/esignet) — all endpoint paths are appended to it; also the default expectediss(OIDC_ISSUER_URLonly as an override)CLIENT_IDREDIRECT_URICLIENT_KEY_PATH,CLIENT_KIDkidforclient_assertion; not needed for public-client-only runsSCOPEopenid profile email)scopes_supportedAUTHN_PROVIDERmock|mosip|sunbird— gates plugin scenariosTEST_PROFILESsmoke)smoke|positive|negative|plugin|regression(comma-combinable; scenarios are multi-tagged)TEST_RUNgo test -run); overrides profiles. Each scenario embeds its own flow dependency, so one testcase runs completeTEST_USERNAME(mock) /INDIVIDUAL_ID(mosip/sunbird)TEST_PASSWORD(mock)TEST_DATA_PATHOTP_SOURCEstatic)static|dynamic— an orthogonal input-resolution concern, deliberately NOT tied to any plugin/provider name: any flow whose steps request anotpinput uses the configured source.staticsubmitsTEST_OTP;dynamicfetches the real OTP from the environment's mock-SMTP streamTEST_OTP111111)OTP_SOURCE=static(sandbox identities are configured to accept it)OTP_WS_URLdynamicwss://smtp.<domain>/mocksmtp/websocket). Explicit config, not derived — the SMTP host can differ from the target host/domain, so it cannot be inferred fromTARGET_BASE_URLOTP_RECIPIENT_EMAILdynamicEXPECTED_CLAIMSclaims_supportedHTTP_TIMEOUTREPORT_DIRout)FAIL_FASTMultiple test-data inputs (smoke & negative variants)
Smoke and negative scenarios need more than one value per input, so alongside the single-value shorthands above, the optional test-data set (
TEST_DATA_PATH) carries lists:clients[]— multiple client entries with a role tag: the confidential client (key + kid), a public client (no assertion), and a deliberately unregistered client id for the unknown-client negative.identities[]— multiple subject entries with an id-type tag —uin|vid|phone|username— plus per-identity credential/OTP expectation, and deliberately invalid entries for negatives.Scenarios pick entries by role and id type: smoke uses the primary client + identity; plugin positives run each id type applicable to the provider (e.g. MOSIP OTP by UIN and by VID, phone where the env supports it); negatives use the unregistered client and invalid identities. The single-value env vars remain the shorthand for the primary entries, so simple runs need no data file.
Non-Functional Requirements (As Applicable)
Operational Considerations (As Applicable)
emptyDiris ephemeral — the report dies with the pod. For real use, persist to a PVC or object store (S3/MinIO) or publish to a static location so the artifact survives. (Roadmap.)backoffLimitrestarts, to reduce false reds.go.mod, add a dedicated CI job (go build/go test/go vet) and a.golangci.ymlmirroring the service's linters, so the rig is built on every change and doesn't rot. Pin the container image by tag/digest rather than:latest.Definition of Done (Mandatory)
Delivery is strictly phased: the framework lands first (this issue); then Phase 2 delivers one plugin's positive flow together with the report so the whole rig is verified end to end; then the remaining plugins' positive flows follow one phase at a time; and only after all positive flows are proven do negatives and the remaining scope follow (see Roadmap).
Phase 1 — framework (this issue):
client_assertion(private_key_jwt) signing withgolang-jwt/flow/executeresponse advertises (no hardcoded action refs), with the expected steps declared per scenario and consent auto-approve support; no open-ended loop-until-COMPLETETEST_PROFILES: smoke/positive/negative/plugin/regression, combinable) with mutually independent scenarios/.well-known/openid-configuration; config reduced to the non-discoverable minimum (target, client credentials, identity, profile)client_assertionsigning) and passing (go test ./...).golangci.ymlfor the new modulePhase 2 — first plugin positive flow + report (verifies the framework end to end):
client_assertionhappy path (authorize → step-wise flow execution → callback → token → userinfo) against a real running esignet-service,AUTHN_PROVIDER=mock/flow/executeresponse (HTTP 200,challengeToken, noerror/fieldErrors, well-formedactions/inputs;assertionpresent onCOMPLETE), each step named in the reportGET /flow/metafor the client under test: i18n (languages,translations), design (theme,layout), application/client metadata (application.name,application.logoUrl)jwks_uri(signature +iss/aud/exp),stateround-trip asserted, and userinfo claims checked against expected (config,EXPECTED_CLAIMS)Phase 3 — MOSIP OTP positive flow: send-OTP executor → KYC auth → exchange,
AUTHN_PROVIDER=mosip, OTP viastaticanddynamic(mock-SMTP) sources — the OTP source is plugin-agnostic config; run per id type from the test-data set (UIN and VID, phone where the env supports it); verified via the same report.Phase 4 — Sunbird KBI positive flow:
AUTHN_PROVIDER=sunbird, KBI answers from config; verified via the same report.Phase 5+ — negatives & remaining scope (delivered after the positive flows; independently runnable via profiles): input-driven negatives, PAR and DPoP scenarios (gated on discovery advertising them), client-management CRUD, JUnit XML, report publishing, alerting — see Roadmap.
5. References (Mandatory)
Documentation (As Applicable)
.env.example— architecture,AUTHN_PROVIDERmodes, configuration, and issuer/base-URL rules.UI/UX Design Link (As Applicable)
Not applicable (HTML report layout to be defined during implementation).
Git Discussion Link (As Applicable)
internal/-only and therefore not importable by this separate module): https://github.com/thunder-id/thunderid —pkg/thunderidengine. The rig re-implements the equivalent client primitives with the standard library +golang-jwt.esignet-service/scripts/oauth-smoke.sh(same flow: authorize → flow/execute → callback → token → userinfo).Related Issues / PRs (As Applicable)
scripts/oauth-smoke.sh,scripts/client-mgmt-smoke.sh,scripts/smoke-client-lib.sh,postman/embedder-positive-flow.json.6. Roadmap / Phased Delivery
The delivery order is deliberately framework first, then one positive flow per plugin, then everything else — each phase must be green (verified via the report against a real environment) before the next starts:
/flow/executeassertions and flow metadata validation — plus the HTML/JSON report, verifying the whole rig (framework → real service → report artifact) works before further plugins are added.Each plugin phase just adds a provider-scoped
Scenarioon the same framework, driven entirely by the config inputs above (notably the per-providerclient_idand individual id):AUTHN_PROVIDER=mosip; individual id + OTP source from config. The MOSIPAuthenticateplugin validates the OTP against real MOSIP IDA (credentials["otp"]), so the rig must obtain the actual OTP. The OTP source is a flow-input-resolution concern, deliberately not tied to any plugin name — whenever a flow step requests anotpinput, the configured source supplies it, whichever provider owns the flow. Both sources are supported:static— a fixed OTP (TEST_OTP); sandbox identities are configured to accept it.dynamic— mirror the existing MOSIP Java rig (OTPListener+NotificationListenerinmosip-functional-tests/ the legacyapi-test): a WebSocket client connects to the environment's mock-SMTP stream at the configuredOTP_WS_URL(commonlywss://smtp.<domain>/mocksmtp/websocket, but the SMTP host can differ from the target host, so the URL is explicit config rather than derived), receives each captured email, regex-extracts the 6-digit code (\b(\d{6})\b), and keys it by recipient mailbox. The rig starts the listener before the send-OTP step, then retrieves the code (poll with timeout) and submits it in the auth step. Requires an env that runsmocksmtp(sandbox/QA); it cannot work against locked-down prod where OTP reaches real inboxes. Adds a WebSocket client dependency (e.g.github.com/coder/websocket).AUTHN_PROVIDER=sunbird; individual id + KBI answers from config.TEST_PROFILES=negative.pushed_authorization_request_endpoint, then authorize viarequest_uri; honourrequire_pushed_authorization_requestswhen the env mandates it. DPoP (RFC 9449): send DPoP proof JWTs on token/userinfo and assert token binding for clients configured withdpopBoundAccessTokens. Both are gated on the discovery document advertising support, so the same rig runs against targets with or without these features./client-mgmt/...parity withclient-mgmt-smoke.sh.emptyDirdefault.