diff --git a/backend/migrations/20260802172300_notify_user_email_change.up.sql b/backend/migrations/20260802172300_notify_user_email_change.up.sql index 5ab41a4852..5e84ccc899 100644 --- a/backend/migrations/20260802172300_notify_user_email_change.up.sql +++ b/backend/migrations/20260802172300_notify_user_email_change.up.sql @@ -44,19 +44,18 @@ EXECUTE FUNCTION notify_usr_email_change(); CREATE OR REPLACE FUNCTION notify_superadmin_identity_change() RETURNS TRIGGER AS $$ DECLARE - new_name TEXT; - old_name TEXT; + names TEXT[] := '{}'; BEGIN - IF TG_OP <> 'DELETE' THEN new_name := COALESCE(NEW.username, NEW.email); END IF; - IF TG_OP <> 'INSERT' THEN old_name := COALESCE(OLD.username, OLD.email); END IF; - IF new_name IS NOT NULL THEN - INSERT INTO notify_event (channel, payload) - VALUES ('notify_user_email_change', '*:' || new_name); - END IF; - IF old_name IS NOT NULL AND old_name IS DISTINCT FROM new_name THEN - INSERT INTO notify_event (channel, payload) - VALUES ('notify_user_email_change', '*:' || old_name); - END IF; + -- Both columns, not `COALESCE`: `resolve_username_to_email` matches a `u/` principal against + -- `username` OR `email`, and whichever string the caller passed is the key it cached under, + -- so an account with a username can still hold a live entry under its address. Old and new + -- of each, because a change to either leaves the other's entry behind. + IF TG_OP <> 'DELETE' THEN names := names || ARRAY[NEW.username, NEW.email]; END IF; + IF TG_OP <> 'INSERT' THEN names := names || ARRAY[OLD.username, OLD.email]; END IF; + INSERT INTO notify_event (channel, payload) + SELECT DISTINCT 'notify_user_email_change', '*:' || n + FROM unnest(names) AS n + WHERE n IS NOT NULL; RETURN COALESCE(NEW, OLD); END; $$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/backend/windmill-common/tests/notify_events.rs b/backend/windmill-common/tests/notify_events.rs index cde787a9eb..6b3ec22eec 100644 --- a/backend/windmill-common/tests/notify_events.rs +++ b/backend/windmill-common/tests/notify_events.rs @@ -383,22 +383,27 @@ async fn test_trigger_notify_user_email_change(db: Pool) { // A superadmin outside their workspaces resolves through `password`, which names no // workspace: the wildcard is the only way to reach that key. `super_admin` is half of what // that fallback matches on, so losing it moves the mapping just as the address does. - for (label, stmt) in [ + for (label, stmt, expected_aliases) in [ ( "email change", "UPDATE password SET email = 'sa2@windmill.dev' WHERE email = 'test@windmill.dev'", + // old address, new address, and the username that outlives both + vec!["test@windmill.dev", "sa2@windmill.dev", "test-user"], ), ( "demotion", "UPDATE password SET super_admin = false WHERE email = 'sa2@windmill.dev'", + vec!["sa2@windmill.dev", "test-user"], ), ( "promotion", "UPDATE password SET super_admin = true WHERE email = 'sa2@windmill.dev'", + vec!["sa2@windmill.dev", "test-user"], ), ( "deletion", "DELETE FROM password WHERE email = 'sa2@windmill.dev'", + vec!["sa2@windmill.dev", "test-user"], ), ] { let before_id = get_latest_event_id(&db).await.unwrap(); @@ -410,11 +415,19 @@ async fn test_trigger_notify_user_email_change(db: Pool) { let events = poll_notify_events(&db, before_id) .await .expect("Should poll events"); - assert!( - events.iter().any(|e| e.channel == "notify_user_email_change" - && e.payload.starts_with("*:")), - "superadmin {label} should emit a workspace-wildcard eviction for that name" - ); + // Every alias the principal can be spelled as, since `resolve_username_to_email` + // matches a `u/` principal against the username or the address. + let evicted: Vec<&str> = events + .iter() + .filter(|e| e.channel == "notify_user_email_change") + .filter_map(|e| e.payload.strip_prefix("*:")) + .collect(); + for alias in expected_aliases { + assert!( + evicted.contains(&alias), + "superadmin {label} should evict {alias}, got {evicted:?}" + ); + } } }