From be5b278069f8a4e829dff52ee97eafd38a78955a Mon Sep 17 00:00:00 2001 From: Livio Spring <9405495+livio-a@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:50:21 +0200 Subject: [PATCH] test(login): avoid assert.Never race in forged registration check (#12548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved - `TestExternalNotFoundOption_ForgedRegistration_IsRejected` flakes across unrelated PRs: the test reaches PASS, then panics with `Log in goroutine after Test... has completed` and `rpc error: code = Canceled desc = context canceled`. # How the Problems Are Solved - Replaces `assert.Never` with a synchronous poll helper (`requireNeverUserByEmail`) so `ListUsers` and `require` run on the test goroutine and finish before `TestMain` cancels `CTX`. - Keeps the same “must stay absent for a window” polarity (not `Eventually`), so a late-projected forged user still fails the test. # Additional Changes - None. # Additional Context - CI examples: [run 30980062306](https://github.com/zitadel/zitadel/actions/runs/30980062306), [run 30837302974](https://github.com/zitadel/zitadel/actions/runs/30837302974) Co-authored-by: Cursor --- .../integration_test/external_login_test.go | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/internal/api/ui/login/integration_test/external_login_test.go b/internal/api/ui/login/integration_test/external_login_test.go index 32bbd9fc0..ed763ce9f 100644 --- a/internal/api/ui/login/integration_test/external_login_test.go +++ b/internal/api/ui/login/integration_test/external_login_test.go @@ -190,13 +190,10 @@ func TestExternalNotFoundOption_ForgedRegistration_IsRejected(t *testing.T) { assert.Contains(t, body, "LOGIN-Dju3f", "forged registration should be rejected by the LinkingUsers guard; got a page without that rejection (attack likely succeeded)") - // The load-bearing assertion: no account may exist for the attacker's forged identity. We use - // assert.Never (not Eventually) so that if the account IS created but its projection lags, the user - // still surfaces within the window and fails the test - which is exactly the unpatched behavior. - assert.Never(t, func() bool { - return userCountByEmail(t, forgedEmail) > 0 - }, 5*time.Second, 250*time.Millisecond, - "a user was created for the attacker-forged external identity %q - account pre-hijack succeeded", forgedEmail) + // No account may exist for the forged identity. Poll on the test goroutine for a window so a + // late-projected user still fails the test (assert.Never is unsafe here: its condition runs in a + // background goroutine that can outlive the test and panic on CTX cancel from TestMain). + requireNeverUserByEmail(t, forgedEmail, 5*time.Second, 250*time.Millisecond) } // --- helpers ------------------------------------------------------------------------------------ @@ -248,14 +245,22 @@ func doPostForm(t *testing.T, c *http.Client, rawURL string, form url.Values) st return string(b) } -// userCountByEmail returns how many users match the given email address (via the User v2 API). -func userCountByEmail(t *testing.T, email string) int { +// requireNeverUserByEmail fails if a user with the given email appears within waitFor. +func requireNeverUserByEmail(t *testing.T, email string, waitFor, tick time.Duration) { t.Helper() - resp, err := Instance.Client.UserV2.ListUsers(CTX, &user.ListUsersRequest{ - Queries: []*user.SearchQuery{ - {Query: &user.SearchQuery_EmailQuery{EmailQuery: &user.EmailQuery{EmailAddress: email}}}, - }, - }) - require.NoError(t, err) - return len(resp.GetResult()) + deadline := time.Now().Add(waitFor) + for { + resp, err := Instance.Client.UserV2.ListUsers(CTX, &user.ListUsersRequest{ + Queries: []*user.SearchQuery{ + {Query: &user.SearchQuery_EmailQuery{EmailQuery: &user.EmailQuery{EmailAddress: email}}}, + }, + }) + require.NoError(t, err) + require.Empty(t, resp.GetResult(), + "a user was created for the attacker-forged external identity %q - account pre-hijack succeeded", email) + if !time.Now().Before(deadline) { + return + } + time.Sleep(tick) + } }