From 72ce5d3e5c36f0d94fc113da4141fe31c23881d1 Mon Sep 17 00:00:00 2001 From: "mdhumair.kankudti" Date: Tue, 3 Nov 2026 10:07:12 +0530 Subject: [PATCH 1/4] conformance test fixes Signed-off-by: mdhumair.kankudti --- api-test/data/config/config.mock.json | 34 +++++--- api-test/internal/conformance/client.go | 13 +++ api-test/internal/conformance/run.go | 101 +++++++++++++++++++++--- 3 files changed, 124 insertions(+), 24 deletions(-) diff --git a/api-test/data/config/config.mock.json b/api-test/data/config/config.mock.json index 1029d0f21..2ea7c5c72 100644 --- a/api-test/data/config/config.mock.json +++ b/api-test/data/config/config.mock.json @@ -27,16 +27,6 @@ "tls_verify": false }, "plans": [ - { - "name": "oidcc-test-plan", - "variant": { - "client_auth_type": "private_key_jwt", - "response_type": "code", - "response_mode": "default", - "client_registration": "static_client" - }, - "config_file": "conformance-suite-private/esignet-config.json" - }, { "name": "fapi2-security-profile-final-test-plan", "variant": { @@ -46,8 +36,28 @@ "openid": "openid_connect", "fapi_profile": "plain_fapi" }, - "config_file": "conformance-suite-private/esignet-fapi2-config.json", + "config_file": "conformance-suite-private/esignet-fapi2-config-copy.json", "profile": "full" + }, + { + "name": "oidcc-test-plan", + "variant": { + "client_auth_type": "private_key_jwt", + "response_type": "code", + "response_mode": "default", + "client_registration": "static_client" + }, + "config_file": "conformance-suite-private/esignet-config-copy.json", + "skip": [ + "oidcc-refresh-token", + "oidcc-ensure-request-object-with-redirect-uri" + ], + "known_issues": [ + { + "module": "oidcc-discovery-endpoint-verification", + "reason": "Only PS256 key registered on this deployment; OIDC Core §3.1.3.7 mandates RS256 in id_token_signing_alg_values_supported. Fix: register an RS256 key in the crypto store." + } + ] } ], "keycloak": { @@ -57,7 +67,7 @@ "provider": "mock", "auth_factor": "otp", "identity": { - "individual_id": "8267411574", + "individual_id": "1774231388", "id_type": "uin" }, "credentials": { diff --git a/api-test/internal/conformance/client.go b/api-test/internal/conformance/client.go index 3bff2e0e6..b81c69479 100644 --- a/api-test/internal/conformance/client.go +++ b/api-test/internal/conformance/client.go @@ -205,6 +205,19 @@ type DeliverResult struct { var implicitSubmitRe = regexp.MustCompile(`"implicitSubmitUrl"\s*:\s*"([^"]+)"`) var implicitPathRe = regexp.MustCompile(`https?://[^"'\s]+/implicit/[^"'\s]+`) +// Continue sends the operator "Continue" signal to a test that is paused waiting for an operator action +// (e.g. oidcc-server-rotate-keys waits at CONFIGURED before the key rotation step). +func (c *Client) Continue(ctx context.Context, testID string) error { + body, status, err := c.do(ctx, "suite /api/runner (continue)", http.MethodPost, "/api/runner/"+testID, []byte(`{"action":"continue"}`), "application/json") + if err != nil { + return err + } + if status != http.StatusOK && status != http.StatusNoContent { + return fmt.Errorf("continue HTTP %d: %s", status, snippet(body)) + } + return nil +} + // DeliverCallback performs the suite's two-request hand-off: GET the redirect_uri, then POST implicitSubmitUrl. func (c *Client) DeliverCallback(ctx context.Context, redirectURI string) (DeliverResult, error) { var res DeliverResult diff --git a/api-test/internal/conformance/run.go b/api-test/internal/conformance/run.go index 64ce2c883..2c3f8619e 100644 --- a/api-test/internal/conformance/run.go +++ b/api-test/internal/conformance/run.go @@ -39,6 +39,16 @@ type moduleBehavior struct { loadOnlyVisits int // maxVisits is how many times the same browser URL may be driven; 0 means 1. maxVisits int + // allowErrorPage means eSignet's HTML error page (no redirect) is the expected outcome for this module. + // driveOne logs the error but does not abort the outer poll loop, so the suite can reach its own terminal status. + allowErrorPage bool + // needsContinue sends the suite's operator "Continue" signal once when the module is in CONFIGURED/WAITING + // with no pending browser URLs (e.g. oidcc-server-rotate-keys pauses for a key-rotation operator step). + needsContinue bool + // timeoutSeconds overrides the global run timeout for this module; 0 means use the global value. + // Used for modules where the suite self-completes after its own internal timeout (e.g. allowErrorPage + // tests where no callback arrives and the suite eventually marks PASSED itself). + timeoutSeconds int } // moduleBehaviors maps a module-name substring to the behaviour it needs, matched the same way as unsupportedModuleHints. @@ -53,6 +63,13 @@ var moduleBehaviors = map[string]moduleBehavior{ loadOnlyVisits: 1, maxVisits: 2, }, + // eSignet correctly shows an HTML error page (no redirect) when redirect_uri is invalid/unregistered. + // The suite self-completes after its own internal module timeout (typically ~5 min) once no callback + // arrives. The harness uses a longer timeout so it outlasts the suite's wait instead of cutting it short. + "ensure-registered-redirect-uri": {allowErrorPage: true, timeoutSeconds: 600}, + "redirect-uri-query-added": {allowErrorPage: true, timeoutSeconds: 600}, + // The suite pauses at CONFIGURED waiting for the operator to rotate keys; send Continue automatically. + "server-rotate-keys": {needsContinue: true}, } // behaviorFor returns the behaviour configured for a module, or the zero value. @@ -161,6 +178,12 @@ func (o *Orchestrator) runPlan(ctx context.Context, p config.Plan, driver *esign } o.logf("created plan %s (id=%s, %d modules)", plan.Name, plan.ID, len(plan.Modules)) + // Extract the alias from the plan config JSON — used to construct synthetic error callbacks. + var planMeta struct { + Alias string `json:"alias"` + } + _ = json.Unmarshal(configBody, &planMeta) + sel := o.cfg.Selection(p) selected, err := o.selectModules(p.Name, sel, plan.Modules) if err != nil { @@ -188,7 +211,7 @@ func (o *Orchestrator) runPlan(ctx context.Context, p config.Plan, driver *esign o.logf("module %-45s -> SKIPPED (config)", res.Module) continue } - res := o.runModule(ctx, plan, m, driver) + res := o.runModule(ctx, plan, m, driver, planMeta.Alias) out.Modules = append(out.Modules, res) o.logf("module %-45s -> %s / %s%s", res.Module, dash(res.Result), res.HarnessOutcome, errSuffix(res)) if o.cfg.Run.FailFast && (res.Result == "FAILED" || res.HarnessError != "") { @@ -284,7 +307,7 @@ func reasonSuffix(reason string) string { return " (" + reason + ")" } -func (o *Orchestrator) runModule(ctx context.Context, plan *PlanResponse, m Module, driver *esignet.Driver) result.ModuleResult { +func (o *Orchestrator) runModule(ctx context.Context, plan *PlanResponse, m Module, driver *esignet.Driver, alias string) result.ModuleResult { res := result.ModuleResult{ Surface: result.SurfaceConformance, Plugin: o.cfg.Esignet.Provider, @@ -314,16 +337,21 @@ func (o *Orchestrator) runModule(ctx context.Context, plan *PlanResponse, m Modu } res.TestID = test.ID - deadline := time.Now().Add(time.Duration(o.cfg.Run.TimeoutSeconds) * time.Second) + // One Driver is shared across every module, so both switches are restored before returning, or a stray DenyAll would deny consent for later modules. + behavior := behaviorFor(m.TestModule) + driver.SetConsentPolicy(behavior.consent) + driver.SetFollowRejection(behavior.followRejection) + + timeoutSec := o.cfg.Run.TimeoutSeconds + if behavior.timeoutSeconds > 0 { + timeoutSec = behavior.timeoutSeconds + } + deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second) poll := time.Duration(o.cfg.Run.PollIntervalSeconds) * time.Second // A zero interval would turn the wait below into a hot loop hammering the suite for the whole timeout window (wsotp.WaitOTP applies the same floor). if poll <= 0 { poll = time.Second } - // One Driver is shared across every module, so both switches are restored before returning, or a stray DenyAll would deny consent for later modules. - behavior := behaviorFor(m.TestModule) - driver.SetConsentPolicy(behavior.consent) - driver.SetFollowRejection(behavior.followRejection) defer func() { driver.SetConsentPolicy(esignet.ConsentPolicy{}) driver.SetFollowRejection(false) @@ -331,6 +359,7 @@ func (o *Orchestrator) runModule(ctx context.Context, plan *PlanResponse, m Modu visits := map[string]int{} visitCount := 0 + continueSent := false for { info, err := o.client.GetInfo(ctx, test.ID) @@ -351,8 +380,11 @@ func (o *Orchestrator) runModule(ctx context.Context, plan *PlanResponse, m Modu } pending := pendingURLs(runner.Browser, visits, behavior.visitBudget()) if len(pending) > 0 { + var errorPageAuthorizeURL string for _, u := range pending { - o.driveOne(ctx, driver, u, &res, visitCount < behavior.loadOnlyVisits) + if o.driveOne(ctx, driver, u, &res, visitCount < behavior.loadOnlyVisits, behavior.allowErrorPage) { + errorPageAuthorizeURL = u + } visits[u]++ visitCount++ if res.HarnessError != "" { @@ -362,6 +394,27 @@ func (o *Orchestrator) runModule(ctx context.Context, plan *PlanResponse, m Modu if res.HarnessError != "" { break } + // When eSignet shows an error page (correct RFC behaviour for invalid redirect_uri), deliver a + // synthetic error callback so the suite can evaluate the test and exit WAITING on its own. + if errorPageAuthorizeURL != "" && alias != "" { + if cbURL, err := syntheticErrorCallbackURL(o.client.baseURL, alias, errorPageAuthorizeURL); err == nil { + dr, _ := o.client.DeliverCallback(ctx, cbURL) + if dr.SuiteCallbackStatus != 0 { + res.FlowTrace.SuiteCallbackStatus = dr.SuiteCallbackStatus + } + if dr.ImplicitSubmitStatus != 0 { + res.FlowTrace.ImplicitSubmitStatus = dr.ImplicitSubmitStatus + } + } + } + } else if behavior.needsContinue && !continueSent { + // Some modules (e.g. oidcc-server-rotate-keys) pause at CONFIGURED waiting for an operator + // action; send the Continue signal once so the suite can proceed without manual intervention. + if err := o.client.Continue(ctx, test.ID); err != nil { + res.HarnessError = "operator continue: " + err.Error() + break + } + continueSent = true } else { time.Sleep(poll) } @@ -400,12 +453,14 @@ func (o *Orchestrator) runModule(ctx context.Context, plan *PlanResponse, m Modu } // driveOne drives a single authorize URL through eSignet and hands the code back to the suite. +// It returns true when eSignet responded with an HTML error page and allowErrorPage is set — +// the caller should then deliver a synthetic error callback so the suite can exit WAITING. func (o *Orchestrator) driveOne(ctx context.Context, driver *esignet.Driver, authorizeURL string, - res *result.ModuleResult, loadOnly bool) { + res *result.ModuleResult, loadOnly bool, allowErrorPage bool) bool { base, err := o.esignetBase(authorizeURL) if err != nil { res.HarnessError = err.Error() - return + return false } // A load-only visit stops at the login page on purpose, so there is no assertion and no redirect to deliver — the suite drives the next visit. @@ -419,7 +474,7 @@ func (o *Orchestrator) driveOne(ctx context.Context, driver *esignet.Driver, aut } else if !flow.LoginReached { res.HarnessError = "eSignet flow: login page not reached on the load-only visit" } - return + return false } flow := driver.Run(ctx, base, authorizeURL) @@ -428,8 +483,13 @@ func (o *Orchestrator) driveOne(ctx context.Context, driver *esignet.Driver, aut res.FlowTrace.EsignetCallbackStatus = flow.CallbackStatus res.Calls = append(res.Calls, flow.Calls...) if !flow.OK() { + // For modules where an HTML error page is the expected server response (e.g. invalid/unregistered + // redirect_uri), signal the caller so it can deliver a synthetic error callback to the suite. + if allowErrorPage && flow.AuthorizeErrorCode != "" { + return true + } res.HarnessError = "eSignet flow: " + flow.Error - return + return false } deliver, err := o.client.DeliverCallback(ctx, flow.RedirectURI) @@ -439,6 +499,23 @@ func (o *Orchestrator) driveOne(ctx context.Context, driver *esignet.Driver, aut if err != nil { res.HarnessError = "deliver callback: " + err.Error() } + return false +} + +// syntheticErrorCallbackURL builds the suite callback URL for an error response. +// When eSignet shows an HTML error page (correct RFC behaviour), the harness uses this URL to +// deliver error=invalid_request to the suite so it can evaluate the test and exit WAITING. +func syntheticErrorCallbackURL(suiteBase, alias, authorizeURL string) (string, error) { + u, err := url.Parse(authorizeURL) + if err != nil { + return "", fmt.Errorf("parse authorize URL: %w", err) + } + state := u.Query().Get("state") + if state == "" { + return "", fmt.Errorf("no state param in authorize URL") + } + return fmt.Sprintf("%s/test/a/%s/callback?error=invalid_request&state=%s", + strings.TrimRight(suiteBase, "/"), alias, url.QueryEscape(state)), nil } // esignetBase returns the configured eSignet base URL, or derives it from the authorize URL. From 68bde63a829aa43240b88fadc07fc94c2212698a Mon Sep 17 00:00:00 2001 From: "mdhumair.kankudti" Date: Wed, 4 Nov 2026 17:28:20 +0530 Subject: [PATCH 2/4] code rabbit fixes Signed-off-by: mdhumair.kankudti --- api-test/internal/conformance/run.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/api-test/internal/conformance/run.go b/api-test/internal/conformance/run.go index 2c3f8619e..022bdc4e1 100644 --- a/api-test/internal/conformance/run.go +++ b/api-test/internal/conformance/run.go @@ -398,13 +398,17 @@ func (o *Orchestrator) runModule(ctx context.Context, plan *PlanResponse, m Modu // synthetic error callback so the suite can evaluate the test and exit WAITING on its own. if errorPageAuthorizeURL != "" && alias != "" { if cbURL, err := syntheticErrorCallbackURL(o.client.baseURL, alias, errorPageAuthorizeURL); err == nil { - dr, _ := o.client.DeliverCallback(ctx, cbURL) + dr, deliverErr := o.client.DeliverCallback(ctx, cbURL) if dr.SuiteCallbackStatus != 0 { res.FlowTrace.SuiteCallbackStatus = dr.SuiteCallbackStatus } if dr.ImplicitSubmitStatus != 0 { res.FlowTrace.ImplicitSubmitStatus = dr.ImplicitSubmitStatus } + if deliverErr != nil { + res.HarnessError = "deliver synthetic callback: " + deliverErr.Error() + break + } } } } else if behavior.needsContinue && !continueSent { @@ -421,7 +425,7 @@ func (o *Orchestrator) runModule(ctx context.Context, plan *PlanResponse, m Modu if time.Now().After(deadline) { if res.HarnessError == "" { - res.HarnessError = fmt.Sprintf("timeout after %ds (last status %s)", o.cfg.Run.TimeoutSeconds, res.Status) + res.HarnessError = fmt.Sprintf("timeout after %ds (last status %s)", timeoutSec, res.Status) } break } @@ -486,6 +490,7 @@ func (o *Orchestrator) driveOne(ctx context.Context, driver *esignet.Driver, aut // For modules where an HTML error page is the expected server response (e.g. invalid/unregistered // redirect_uri), signal the caller so it can deliver a synthetic error callback to the suite. if allowErrorPage && flow.AuthorizeErrorCode != "" { + res.OutcomeDetail = "authorize error: " + flow.AuthorizeErrorCode return true } res.HarnessError = "eSignet flow: " + flow.Error From 00fc35e829e5ded95dc28b0864e2db5ac4a8298a Mon Sep 17 00:00:00 2001 From: "mdhumair.kankudti" Date: Wed, 4 Nov 2026 18:41:22 +0530 Subject: [PATCH 3/4] fix: align map literal spacing to satisfy gofmt The extra alignment space in `"redirect-uri-query-added"` caused gofmt to flag run.go, which failed the CI gofmt check. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: mdhumair.kankudti --- api-test/internal/conformance/run.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api-test/internal/conformance/run.go b/api-test/internal/conformance/run.go index 022bdc4e1..2eecbd894 100644 --- a/api-test/internal/conformance/run.go +++ b/api-test/internal/conformance/run.go @@ -67,7 +67,7 @@ var moduleBehaviors = map[string]moduleBehavior{ // The suite self-completes after its own internal module timeout (typically ~5 min) once no callback // arrives. The harness uses a longer timeout so it outlasts the suite's wait instead of cutting it short. "ensure-registered-redirect-uri": {allowErrorPage: true, timeoutSeconds: 600}, - "redirect-uri-query-added": {allowErrorPage: true, timeoutSeconds: 600}, + "redirect-uri-query-added": {allowErrorPage: true, timeoutSeconds: 600}, // The suite pauses at CONFIGURED waiting for the operator to rotate keys; send Continue automatically. "server-rotate-keys": {needsContinue: true}, } From a48624f5d1c238e3b080c5cc16eed291f472e853 Mon Sep 17 00:00:00 2001 From: "mdhumair.kankudti" Date: Wed, 4 Nov 2026 21:09:31 +0530 Subject: [PATCH 4/4] fix: apply CodeRabbit review comments (round 2) run.go: - driveOne: validate AuthorizeErrorCode against expectedErrorCode for allowErrorPage modules; fail module when code differs from expected - runModule: preserve syntheticErrorCallbackURL construction error in OutcomeDetail instead of silently dropping it - runModule: gate needsContinue branch on info.Status == "CONFIGURED" to avoid sending Continue before the module is ready config.mock.json: - reference esignet-fapi2-config.json / esignet-config.json instead of the -copy variants Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: mdhumair.kankudti --- api-test/data/config/config.mock.json | 202 +++++++++++++------------- api-test/internal/conformance/run.go | 22 ++- 2 files changed, 117 insertions(+), 107 deletions(-) diff --git a/api-test/data/config/config.mock.json b/api-test/data/config/config.mock.json index 2ea7c5c72..ae09da9e0 100644 --- a/api-test/data/config/config.mock.json +++ b/api-test/data/config/config.mock.json @@ -1,102 +1,102 @@ -{ - "_comment": [ - "mock plugin — the reference run. Its identity and login are synthetic seed", - "data, so they are committed here rather than kept in config.local.json.", - "Run: ./run-all.sh -c data/config/config.mock.json", - "", - "Two conformance plans run back to back here, each with its own client/jwks", - "file (both gitignored — see conformance-suite-private/). The fapi2 entry pins", - "profile=full, which is also the default, so narrowing run.profile to smoke", - "cannot send it looking for a curated list that does not ship. api and", - "e2e still run once, and everything lands in one report. To run only one of", - "them: ./run-all.sh -c data/config/config.mock.json --check to see the order, then drop the", - "entry you do not want (or point both at the same config_file if the same", - "clients are registered for both profiles).", - "", - "api.flow_client_id is a client id registered on YOUR deployment, so it is left", - "empty here — the authorize-negative scenarios report ENV_NOT_READY until you", - "set it in config.local.json or via FLOW_CLIENT_ID.", - "", - "Also required from config.local.json or the environment, because this file", - "selects all three surfaces: esignet.base_url (api + e2e) and", - "keycloak.token_url + keycloak.client_secret (e2e registers a throwaway", - "client before it runs). Without them the run stops at config validation." - ], - "conformance": { - "base_url": "https://localhost.emobix.co.uk:8443", - "tls_verify": false - }, - "plans": [ - { - "name": "fapi2-security-profile-final-test-plan", - "variant": { - "client_auth_type": "private_key_jwt", - "sender_constrain": "dpop", - "authorization_request_type": "simple", - "openid": "openid_connect", - "fapi_profile": "plain_fapi" - }, - "config_file": "conformance-suite-private/esignet-fapi2-config-copy.json", - "profile": "full" - }, - { - "name": "oidcc-test-plan", - "variant": { - "client_auth_type": "private_key_jwt", - "response_type": "code", - "response_mode": "default", - "client_registration": "static_client" - }, - "config_file": "conformance-suite-private/esignet-config-copy.json", - "skip": [ - "oidcc-refresh-token", - "oidcc-ensure-request-object-with-redirect-uri" - ], - "known_issues": [ - { - "module": "oidcc-discovery-endpoint-verification", - "reason": "Only PS256 key registered on this deployment; OIDC Core §3.1.3.7 mandates RS256 in id_token_signing_alg_values_supported. Fix: register an RS256 key in the crypto store." - } - ] - } - ], - "keycloak": { - "client_id": "mosip-pms-client" - }, - "esignet": { - "provider": "mock", - "auth_factor": "otp", - "identity": { - "individual_id": "1774231388", - "id_type": "uin" - }, - "credentials": { - "username": "decl-user-1", - "password": "Mosip@123", - "biometric": "mock-biometric-capture-e2e" - }, - "otp": { - "source": "static", - "value": "111111" - } - }, - "api": { - "tags": "", - "flow_client_id": "" - }, - "e2e": { - "spec": "data/scenarios/e2e-scenarios.json", - "auth_factors": [], - "include": [], - "exclude": [] - }, - "run": { - "surfaces": [ - "conformance", - "api", - "e2e" - ], - "profile": "full", - "debug_show_secrets": false - } +{ + "_comment": [ + "mock plugin — the reference run. Its identity and login are synthetic seed", + "data, so they are committed here rather than kept in config.local.json.", + "Run: ./run-all.sh -c data/config/config.mock.json", + "", + "Two conformance plans run back to back here, each with its own client/jwks", + "file (both gitignored — see conformance-suite-private/). The fapi2 entry pins", + "profile=full, which is also the default, so narrowing run.profile to smoke", + "cannot send it looking for a curated list that does not ship. api and", + "e2e still run once, and everything lands in one report. To run only one of", + "them: ./run-all.sh -c data/config/config.mock.json --check to see the order, then drop the", + "entry you do not want (or point both at the same config_file if the same", + "clients are registered for both profiles).", + "", + "api.flow_client_id is a client id registered on YOUR deployment, so it is left", + "empty here — the authorize-negative scenarios report ENV_NOT_READY until you", + "set it in config.local.json or via FLOW_CLIENT_ID.", + "", + "Also required from config.local.json or the environment, because this file", + "selects all three surfaces: esignet.base_url (api + e2e) and", + "keycloak.token_url + keycloak.client_secret (e2e registers a throwaway", + "client before it runs). Without them the run stops at config validation." + ], + "conformance": { + "base_url": "https://localhost.emobix.co.uk:8443", + "tls_verify": false + }, + "plans": [ + { + "name": "fapi2-security-profile-final-test-plan", + "variant": { + "client_auth_type": "private_key_jwt", + "sender_constrain": "dpop", + "authorization_request_type": "simple", + "openid": "openid_connect", + "fapi_profile": "plain_fapi" + }, + "config_file": "conformance-suite-private/esignet-fapi2-config.json", + "profile": "full" + }, + { + "name": "oidcc-test-plan", + "variant": { + "client_auth_type": "private_key_jwt", + "response_type": "code", + "response_mode": "default", + "client_registration": "static_client" + }, + "config_file": "conformance-suite-private/esignet-config.json", + "skip": [ + "oidcc-refresh-token", + "oidcc-ensure-request-object-with-redirect-uri" + ], + "known_issues": [ + { + "module": "oidcc-discovery-endpoint-verification", + "reason": "Only PS256 key registered on this deployment; OIDC Core §3.1.3.7 mandates RS256 in id_token_signing_alg_values_supported. Fix: register an RS256 key in the crypto store." + } + ] + } + ], + "keycloak": { + "client_id": "mosip-pms-client" + }, + "esignet": { + "provider": "mock", + "auth_factor": "otp", + "identity": { + "individual_id": "1774231388", + "id_type": "uin" + }, + "credentials": { + "username": "decl-user-1", + "password": "Mosip@123", + "biometric": "mock-biometric-capture-e2e" + }, + "otp": { + "source": "static", + "value": "111111" + } + }, + "api": { + "tags": "", + "flow_client_id": "" + }, + "e2e": { + "spec": "data/scenarios/e2e-scenarios.json", + "auth_factors": [], + "include": [], + "exclude": [] + }, + "run": { + "surfaces": [ + "conformance", + "api", + "e2e" + ], + "profile": "full", + "debug_show_secrets": false + } } \ No newline at end of file diff --git a/api-test/internal/conformance/run.go b/api-test/internal/conformance/run.go index 2eecbd894..084689ee0 100644 --- a/api-test/internal/conformance/run.go +++ b/api-test/internal/conformance/run.go @@ -49,6 +49,9 @@ type moduleBehavior struct { // Used for modules where the suite self-completes after its own internal timeout (e.g. allowErrorPage // tests where no callback arrives and the suite eventually marks PASSED itself). timeoutSeconds int + // expectedErrorCode is the OAuth2 error code eSignet must return for allowErrorPage modules. + // When non-empty, driveOne fails the module if the actual code differs. + expectedErrorCode string } // moduleBehaviors maps a module-name substring to the behaviour it needs, matched the same way as unsupportedModuleHints. @@ -66,8 +69,8 @@ var moduleBehaviors = map[string]moduleBehavior{ // eSignet correctly shows an HTML error page (no redirect) when redirect_uri is invalid/unregistered. // The suite self-completes after its own internal module timeout (typically ~5 min) once no callback // arrives. The harness uses a longer timeout so it outlasts the suite's wait instead of cutting it short. - "ensure-registered-redirect-uri": {allowErrorPage: true, timeoutSeconds: 600}, - "redirect-uri-query-added": {allowErrorPage: true, timeoutSeconds: 600}, + "ensure-registered-redirect-uri": {allowErrorPage: true, timeoutSeconds: 600, expectedErrorCode: "invalid_request"}, + "redirect-uri-query-added": {allowErrorPage: true, timeoutSeconds: 600, expectedErrorCode: "invalid_request"}, // The suite pauses at CONFIGURED waiting for the operator to rotate keys; send Continue automatically. "server-rotate-keys": {needsContinue: true}, } @@ -382,7 +385,7 @@ func (o *Orchestrator) runModule(ctx context.Context, plan *PlanResponse, m Modu if len(pending) > 0 { var errorPageAuthorizeURL string for _, u := range pending { - if o.driveOne(ctx, driver, u, &res, visitCount < behavior.loadOnlyVisits, behavior.allowErrorPage) { + if o.driveOne(ctx, driver, u, &res, visitCount < behavior.loadOnlyVisits, behavior.allowErrorPage, behavior.expectedErrorCode) { errorPageAuthorizeURL = u } visits[u]++ @@ -397,7 +400,10 @@ func (o *Orchestrator) runModule(ctx context.Context, plan *PlanResponse, m Modu // When eSignet shows an error page (correct RFC behaviour for invalid redirect_uri), deliver a // synthetic error callback so the suite can evaluate the test and exit WAITING on its own. if errorPageAuthorizeURL != "" && alias != "" { - if cbURL, err := syntheticErrorCallbackURL(o.client.baseURL, alias, errorPageAuthorizeURL); err == nil { + cbURL, cbErr := syntheticErrorCallbackURL(o.client.baseURL, alias, errorPageAuthorizeURL) + if cbErr != nil { + res.OutcomeDetail = "synthetic callback URL: " + cbErr.Error() + } else { dr, deliverErr := o.client.DeliverCallback(ctx, cbURL) if dr.SuiteCallbackStatus != 0 { res.FlowTrace.SuiteCallbackStatus = dr.SuiteCallbackStatus @@ -411,7 +417,7 @@ func (o *Orchestrator) runModule(ctx context.Context, plan *PlanResponse, m Modu } } } - } else if behavior.needsContinue && !continueSent { + } else if behavior.needsContinue && !continueSent && info.Status == "CONFIGURED" { // Some modules (e.g. oidcc-server-rotate-keys) pause at CONFIGURED waiting for an operator // action; send the Continue signal once so the suite can proceed without manual intervention. if err := o.client.Continue(ctx, test.ID); err != nil { @@ -460,7 +466,7 @@ func (o *Orchestrator) runModule(ctx context.Context, plan *PlanResponse, m Modu // It returns true when eSignet responded with an HTML error page and allowErrorPage is set — // the caller should then deliver a synthetic error callback so the suite can exit WAITING. func (o *Orchestrator) driveOne(ctx context.Context, driver *esignet.Driver, authorizeURL string, - res *result.ModuleResult, loadOnly bool, allowErrorPage bool) bool { + res *result.ModuleResult, loadOnly bool, allowErrorPage bool, expectedErrorCode string) bool { base, err := o.esignetBase(authorizeURL) if err != nil { res.HarnessError = err.Error() @@ -490,6 +496,10 @@ func (o *Orchestrator) driveOne(ctx context.Context, driver *esignet.Driver, aut // For modules where an HTML error page is the expected server response (e.g. invalid/unregistered // redirect_uri), signal the caller so it can deliver a synthetic error callback to the suite. if allowErrorPage && flow.AuthorizeErrorCode != "" { + if expectedErrorCode != "" && flow.AuthorizeErrorCode != expectedErrorCode { + res.HarnessError = "unexpected authorize error: " + flow.AuthorizeErrorCode + " (expected " + expectedErrorCode + ")" + return false + } res.OutcomeDetail = "authorize error: " + flow.AuthorizeErrorCode return true }