diff --git a/Sources/App/Controllers/AuthController.swift b/Sources/App/Controllers/AuthController.swift index c2e2fd360..3f448d5bd 100644 --- a/Sources/App/Controllers/AuthController.swift +++ b/Sources/App/Controllers/AuthController.swift @@ -247,10 +247,16 @@ struct AuthController: RouteCollection { case conflict } + /// Redirects with `?link=`, the query key/value contract `users-web`'s + /// `SettingsController`/`settings.leaf` actually reads (`req.query[String.self, at: "link"]`, + /// recognizing only "conflict", "error", and "success"). `.conflict` is the one reason with + /// its own banner copy; every other reason collapses to the generic "error" banner - `users-web` + /// never surfaces the specific denied/expired/unavailable distinction to the visitor. private func linkErrorRedirect(_ req: Request, to returnTo: String, reason: LinkErrorReason) -> Response { - req.redirect(to: "\(returnTo)?link_error=\(reason.rawValue)") + let outcome = reason == .conflict ? "conflict" : "error" + return req.redirect(to: "\(returnTo)?link=\(outcome)") } /// Keyed by the link ticket itself (used as Auth0's `state`) rather than a separate locally @@ -373,6 +379,6 @@ struct AuthController: RouteCollection { return linkErrorRedirect(req, to: returnTo, reason: .unavailable) } - return req.redirect(to: returnTo) + return req.redirect(to: "\(returnTo)?link=success") } } diff --git a/Tests/AppTests/AppTests.swift b/Tests/AppTests/AppTests.swift index 77c94242d..42b90a6ba 100644 --- a/Tests/AppTests/AppTests.swift +++ b/Tests/AppTests/AppTests.swift @@ -480,6 +480,26 @@ struct LinkStartTests { } } + // MARK: - AuthController.linkCallback error redirects + + @Test("linkCallback redirects with link=error, not the stale link_error= query key") + func linkCallbackErrorUsesLinkQueryKey() async throws { + try await withApp(configure: configure) { app in + // Auth0 itself returning an `error` param is the earliest branch in linkCallback - reached + // before any pending-link session lookup or network call, so this needs no fake Auth0/ + // users-api server to exercise. Regression test for the contract users-web's + // SettingsController actually reads (`req.query[String.self, at: "link"]`) - a prior + // version redirected with `?link_error=` instead, which users-web silently ignored + // (no banner shown, confirmed on dev with a real conflict). + try await app.testing().test(.GET, "auth/link/callback?error=access_denied") { res in + #expect(res.status == .seeOther) + let location = res.headers.first(name: .location) ?? "" + #expect(location.contains("link=error")) + #expect(!location.contains("link_error=")) + } + } + } + // MARK: - UsersAPIClient.linkComplete // // Exercised against a real, locally listening fake `users-api` rather than a mocked `Client`,