From bcf07db5820ca2500aea9861601348665f6bccbf Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Wed, 7 Oct 2026 08:25:48 +0200 Subject: [PATCH] feat(members): deactivate sub-keyed members from the id_token sub fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a planner id_token arrived without an `email` claim, the OmniAuth strategy substituted the better-auth user id (`payload['sub']`) as the member email, creating garbage members with no subscriptions or roles — the duplicate-member mechanism behind the 2026-10-02 incident (members 31450 and 31452). The existing duplicate heuristics cannot see these members: they share no name, email, or activity evidence with the real account. Adds `member:subkeyed` rake tasks (`detect` / `deactivate` / `verify`) backed by `SubkeyedMemberCleanup`. Detection is deterministic and planner-side: the member's email equals its own codebar auth service uid, that shared value contains no `@`, and the member was created after the 2026-08-06 auth-flow cutoff. Cleanup is deactivate-only: no merge target is knowable from the planner database, and guessing merges is the harm the duplicate tooling guards against. A detected member owning subscriptions, invitations, roles, bans, feedbacks, or notes is skipped and reported, never touched; skipped members count as handled, so `verify` reaches a clean PASS even while skips remain. Runs default to a dry read; `EXECUTE=1` writes one transaction per member (auth services removed, email renamed `subkeyed..deactivated@codebar.io`, an audit note recording the original email/uid). Re-runs detect nothing — repeatable and idempotent. Pattern and deactivate flow adapted from the temporary duplicate-merge tooling on branch fix/codebar-auth-duplicate-members (draft PR #2809), which stays temporary and unmerged. Sequencing dependency: run production EXECUTE=1 only after #2986 (the fail-closed strategy guard) deploys. --- Makefile | 20 ++ app/services/subkeyed_member_cleanup.rb | 126 ++++++++++ lib/tasks/subkeyed_members.rake | 126 ++++++++++ spec/lib/tasks/subkeyed_members_rake_spec.rb | 180 ++++++++++++++ spec/services/subkeyed_member_cleanup_spec.rb | 230 ++++++++++++++++++ 5 files changed, 682 insertions(+) create mode 100644 app/services/subkeyed_member_cleanup.rb create mode 100644 lib/tasks/subkeyed_members.rake create mode 100644 spec/lib/tasks/subkeyed_members_rake_spec.rb create mode 100644 spec/services/subkeyed_member_cleanup_spec.rb diff --git a/Makefile b/Makefile index ba910c5c4..2b5558b25 100644 --- a/Makefile +++ b/Makefile @@ -92,3 +92,23 @@ test: ## Run the test suite in parallel check: ## Run setup checks bundle exec rake setup:check + +detect_subkeyed_members: ## List sub-keyed members from a local production dump + DB_NAME=$(DUMP_DB) bundle exec rake member:subkeyed:detect + +fix_subkeyed_members: ## Dry-run sub-keyed member deactivation on the dump (EXECUTE=1 executes) + DB_NAME=$(DUMP_DB) bundle exec rake member:subkeyed:deactivate + +verify_subkeyed_members: ## Verify no unhandled sub-keyed members remain in the dump + DB_NAME=$(DUMP_DB) bundle exec rake member:subkeyed:verify + +detect_subkeyed_members_production: ## List sub-keyed members on the PRODUCTION database + @read -p "Connect to PRODUCTION database? [y/N] " ans && [ "$$ans" = "y" ] || exit 1 + @DB_URL=$$(heroku config:get DATABASE_URL --app=$(DUMP_APP)) bundle exec rake member:subkeyed:detect + +fix_subkeyed_members_production: ## Dry-run deactivation on PRODUCTION (EXECUTE=1 executes) + @read -p "This can MODIFY the PRODUCTION database. Continue? [y/N] " ans && [ "$$ans" = "y" ] || exit 1 + @DB_URL=$$(heroku config:get DATABASE_URL --app=$(DUMP_APP)) bundle exec rake member:subkeyed:deactivate + +verify_subkeyed_members_production: ## Verify no unhandled sub-keyed members remain on PRODUCTION + @DB_URL=$$(heroku config:get DATABASE_URL --app=$(DUMP_APP)) bundle exec rake member:subkeyed:verify diff --git a/app/services/subkeyed_member_cleanup.rb b/app/services/subkeyed_member_cleanup.rb new file mode 100644 index 000000000..e025d0914 --- /dev/null +++ b/app/services/subkeyed_member_cleanup.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +# Find and deactivate members keyed on the better-auth user id that the +# planner's id_token sub fallback used as an email (codebar planner, +# 2026-10-02 incident). These members share no name or activity evidence +# with their real account, so name/email duplicate heuristics cannot find +# them. The evidence is planner-side and deterministic: the member's email +# equals its own codebar auth service uid, that shared value contains no +# '@', and the member was created after the auth-flow cutoff. +# +# Cleanup is deactivate-only: no merge target is knowable from the planner +# database (the user-id-to-real-account mapping lives in the auth app's +# database), and guessing a merge target repeats the false-positive merge +# harm the duplicate tooling already guards against (member 31336 / 25796). +# A detected member owning data is skipped, never touched, and counts as +# handled: verify passes while skips are reported, so the "nothing +# unhandled" end state is reachable even when skips remain. +# +# Output is structured (Result/Action); the rake task owns all reporting. +class SubkeyedMemberCleanup + RESULT_FIELDS = %i[detected deactivated skipped actions].freeze + + Action = Data.define(:member_id, :email, :outcome, :owned) + Result = Data.define(*RESULT_FIELDS) + + # The merge of the /auth/codebar sign-in flow into planner (2026-08-06): + # sub-keyed members cannot exist before this point. + CUTOFF_TIME = Time.utc(2026, 8, 6, 15, 25, 11) + + # Email prefixes that mark a member already handled by this cleanup or by + # the tooling it follows (the duplicate-merge rename and member deletion). + HANDLED_EMAIL_PREFIXES = ['subkeyed.', 'duplicate.', 'deleted_user_'].freeze + + # Member associations that prove the member holds real data (skip rule): + # any of these and the member is reported, not deactivated. + OWNED_DATA = { + subscriptions: 'subscriptions', + workshop_invitations: 'workshop invitations', + meeting_invitations: 'meeting invitations', + invitations: 'event invitations', + roles: 'roles', + bans: 'bans', + feedbacks: 'feedbacks', + member_notes: 'member notes' + }.freeze + + def self.call(dry_run: true) + new(dry_run:).call + end + + def initialize(dry_run: true) + @dry_run = dry_run + end + + def call + actions = detectable_members.map { |member| deactivate(member) } + + Result.new(detected: actions.size, + deactivated: actions.count { |a| a.outcome == :deactivated }, + skipped: actions.count { |a| a.outcome == :skipped }, + actions:) + end + + def detectable_members + prefix_exclusions = HANDLED_EMAIL_PREFIXES.map { |prefix| Member.where('members.email LIKE ?', "#{prefix}%") } + + prefix_exclusions.reduce(detection_scope) { |rel, exclusion| rel.where.not(id: exclusion) } + .order(:id) + .to_a + end + + # Verify support: partition detectable members into the set this cleanup + # can and should have deactivated (unhandled) and the set that owns data + # (handled by report; never touched). + def verification + unhandled, skipped = detectable_members.partition { |member| data_owned_by(member).none? } + { unhandled: unhandled.map(&:id), skipped: skipped.map(&:id) } + end + + private + + def detection_scope + Member.joins(:auth_services) + .where(auth_services: { provider: 'codebar' }) + .where('members.created_at > ?', CUTOFF_TIME) + .where('members.email = auth_services.uid') + .where("position('@' in members.email) = 0") + end + + def deactivate(member) + owned = data_owned_by(member) + return skipped_action(member, owned) if owned.any? + + return deactivated_action(member) if @dry_run + + persist_deactivation(member) + deactivated_action(member) + end + + def skipped_action(member, owned) + Action.new(member_id: member.id, email: member.email, outcome: :skipped, owned:) + end + + def deactivated_action(member) + Action.new(member_id: member.id, email: member.email, outcome: :deactivated, owned: []) + end + + def persist_deactivation(member) + original_email = member.email + ActiveRecord::Base.transaction do + member.auth_services.destroy_all + member.update_columns(email: "subkeyed.#{member.id}.deactivated@codebar.io") # rubocop:disable Rails/SkipsModelValidations -- renames fail normal validation by design + member.member_notes.create!(note: note_body(original_email), author_id: member.id) + end + end + + def data_owned_by(member) + OWNED_DATA.filter_map { |relation, label| label if member.public_send(relation).exists? } + end + + def note_body(original_email) + 'Sub-keyed on the better-auth user id (id_token sub fallback). ' \ + "Original email/uid: #{original_email}. Deactivated #{Time.zone.now.iso8601}. " \ + 'Login will stay off until the fail-closed strategy guard ships; re-login re-creates the member.' + end +end diff --git a/lib/tasks/subkeyed_members.rake b/lib/tasks/subkeyed_members.rake new file mode 100644 index 000000000..90f35c076 --- /dev/null +++ b/lib/tasks/subkeyed_members.rake @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +# Detect, deactivate (dry run unless EXECUTE=1), and verify members keyed on +# the auth-app user id. Logic in app/services/subkeyed_member_cleanup.rb; +# this task owns all operator-facing output. +# +# Usage: +# List matches (read-only): +# rake member:subkeyed:detect +# +# Dry-run the deactivation (default; prints what would happen): +# rake member:subkeyed:deactivate +# +# Deactivate for real (one transaction per member): +# EXECUTE=1 rake member:subkeyed:deactivate +# +# Confirm the end state (exits 1 while deactivatable members remain): +# rake member:subkeyed:verify +# +# Run against a local production dump instead of the environment's own +# database: +# DB_NAME=codebar_production_dump rake member:subkeyed:detect +# +# On Heroku (uses the app's DATABASE_URL; no DB_NAME needed): +# heroku run rake member:subkeyed:detect --app codebar-production +# heroku run rake member:subkeyed:deactivate EXECUTE=1 --app codebar-production +# +# Sequencing: run EXECUTE=1 in production only after the fail-closed +# strategy guard (#2986) is deployed. + +def print_actions(result, dry_run: true) + result.actions.each { |action| print_action(action, dry_run) } +end + +def print_action(action, dry_run) + case action.outcome + when :skipped + puts "Skipping sub-keyed member #{action.member_id} (#{action.email}) — owns #{action.owned.join(', ')}. " \ + 'Review manually; merging needs planner-external evidence (an auth-DB link), never a guess.' + when :deactivated + verb = dry_run ? 'Would deactivate' : 'Deactivating' + puts " #{verb} sub-keyed member #{action.member_id} (#{action.email}): " \ + 'remove auth services, rename email, add member note' + end +end + +def print_counts(result) + puts "detected=#{result.detected} deactivated=#{result.deactivated} skipped=#{result.skipped}" +end + +def print_summary(result, dry_run:) + puts 'DRY RUN — re-run with EXECUTE=1 to deactivate.' if dry_run + print_counts(result) + puts 'Skipped members count as handled: review them manually before any merge.' +end + +# When DB_URL or DB_NAME is set, connect to that database (a live production +# database or a local dump) instead of the environment's own. DB_URL takes a +# full connection URL; DB_NAME is a local database on DB_HOST. Otherwise the +# task uses the Rails environment's database (e.g. Heroku's DATABASE_URL). +def connect_to_dump_if_requested + if ENV['DB_URL'].present? + connect_to_remote(ENV['DB_URL']) + elsif ENV['DB_NAME'] + connect_to_local(ENV['DB_NAME']) + end +end + +def connect_to_local(database) + ActiveRecord::Base.establish_connection( + adapter: 'postgresql', + host: ENV.fetch('DB_HOST', '/var/run/postgresql'), + port: ENV.fetch('DB_PORT', 5432), + database:, + username: ENV['DB_USER'] || '', + password: ENV['POSTGRES_PASSWORD'] || '' + ) +end + +def connect_to_remote(db_url) + url = URI.parse(db_url) + unless %w[localhost 127.0.0.1].include?(url.host) + puts "WARNING: Connecting to REMOTE database #{url.host}" + end + ActiveRecord::Base.establish_connection(db_url) +end + +namespace :member do + namespace :subkeyed do + desc 'Detect members keyed on the auth-app user id (id_token sub fallback incident)' + task detect: :environment do + connect_to_dump_if_requested + result = SubkeyedMemberCleanup.call(dry_run: true) + + print_actions(result) + print_counts(result) + end + + desc 'Deactivate sub-keyed members (dry run by default; EXECUTE=1 to write)' + task deactivate: :environment do + connect_to_dump_if_requested + dry_run = ENV['EXECUTE'] != '1' + result = SubkeyedMemberCleanup.call(dry_run:) + + print_actions(result, dry_run:) + print_summary(result, dry_run:) + end + + desc 'Verify no unhandled sub-keyed members remain (deactivatable-but-active fails)' + task verify: :environment do + connect_to_dump_if_requested + verification = SubkeyedMemberCleanup.new.verification + unhandled = verification[:unhandled] + skipped = verification[:skipped] + + if unhandled.empty? + puts 'PASS: no deactivatable sub-keyed members remain.' + puts "Note: #{skipped.size} skipped member(s) count as handled — review manually." if skipped.any? + else + puts "FAIL: #{unhandled.size} deactivatable sub-keyed member(s) still active:" + unhandled.each { |id| puts " member #{id}" } + exit 1 + end + end + end +end diff --git a/spec/lib/tasks/subkeyed_members_rake_spec.rb b/spec/lib/tasks/subkeyed_members_rake_spec.rb new file mode 100644 index 000000000..24a0729d4 --- /dev/null +++ b/spec/lib/tasks/subkeyed_members_rake_spec.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'rake member:subkeyed', type: :task do + let(:subkeyed_key) { 'aB3xK9mQ7vN2pR5sT8uW1yZ4cD6fG0hJ' } + + def create_subkeyed_member(key: subkeyed_key, name_value: 'Viktoriya Kravchenko') + member = Member.new(email: key, name: name_value, surname: 'Kravchenko', + about_you: 'Created by the sub fallback', accepted_toc_at: Time.zone.now) + member.save(validate: false) + member.auth_services.create!(provider: 'codebar', uid: key) + member + end + + describe 'member:subkeyed:detect' do + let(:task) { Rake::Task['member:subkeyed:detect'] } + + after { task.reenable } + + it 'preloads the Rails environment' do + expect(task.prerequisites).to include 'environment' + end + + it 'reports a match without changing anything' do + member = create_subkeyed_member + + expect { task.execute } + .to output(/detected=1/).to_stdout + + expect { member.reload }.not_to change(member, :email) + end + + it 'reports zero when nothing matches' do + Fabricate(:member) + + expect { task.execute } + .to output(/detected=0/).to_stdout + end + end + + describe 'member:subkeyed:deactivate' do + let(:task) { Rake::Task['member:subkeyed:deactivate'] } + + after { task.reenable } + + it 'preloads the Rails environment' do + expect(task.prerequisites).to include 'environment' + end + + it 'is a dry run by default: reports the action and writes nothing' do + member = create_subkeyed_member + + expect { task.execute } + .to output(/detected=1 deactivated=1 skipped=0/).to_stdout + + expect { member.reload }.not_to change(member, :email) + expect(member.auth_services.where(provider: 'codebar')).to exist + expect(MemberNote.where(member_id: member.id)).not_to exist + end + + describe 'with EXECUTE=1' do + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('EXECUTE').and_return('1') + end + + it 'deactivates: renames email, removes auth services, adds a note' do + member = create_subkeyed_member + + expect { task.execute } + .to output(/detected=1 deactivated=1 skipped=0/).to_stdout + + member.reload + expect(member.email).to eq("subkeyed.#{member.id}.deactivated@codebar.io") + expect(member.auth_services).to be_empty + expect(MemberNote.find_by(member_id: member.id).note).to include('better-auth user id') + end + + it 'skips and reports a data-owning member' do + member = create_subkeyed_member + Fabricate(:subscription, member:) + + expect { task.execute } + .to output(/detected=1 deactivated=0 skipped=1/).to_stdout + + member.reload + expect(member.email).to eq(subkeyed_key) + expect(member.subscriptions).to be_present + end + + it 'is idempotent: a second run writes nothing new' do + create_subkeyed_member + task.execute + + expect { task.execute }.not_to change(MemberNote, :count) + end + + it 'drops deactivated members out of detection' do + create_subkeyed_member + task.execute + + detect = Rake::Task['member:subkeyed:detect'] + expect { detect.execute }.to output(/detected=0/).to_stdout + end + end + end + + describe 'member:subkeyed:verify' do + let(:task) { Rake::Task['member:subkeyed:verify'] } + + after { task.reenable } + + # One execute, stdout captured, SystemExit contained. rspec-core re-raises + # SystemExit without recording a failure, and an escaped exit poisons the + # parallel-test child's exit code even when every example is green. + def run_verify + captured = StringIO.new + original_stdout = $stdout + $stdout = captured + status = begin + task.execute + nil + rescue SystemExit => e + e.status + ensure + $stdout = original_stdout + end + [captured.string, status] + end + + it 'preloads the Rails environment' do + expect(task.prerequisites).to include 'environment' + end + + it 'fails with exit status 1, a FAIL line, and the unhandled member id' do + member = create_subkeyed_member + + output, status = run_verify + + expect(status).to eq(1) + expect(output).to include('FAIL: 1 deactivatable sub-keyed member(s) still active:') + expect(output).to include("member #{member.id}") + end + + it 'passes when a sub-keyed member is skipped (handled by report)' do + member = create_subkeyed_member + Fabricate(:subscription, member:) + + output, status = run_verify + + expect(status).to be_nil + expect(output).to include('skipped member') + end + + it 'passes after deactivation' do + create_subkeyed_member + Rake::Task['member:subkeyed:deactivate'].execute # dry run does not write + + # Execute for real: + deactivate = Rake::Task['member:subkeyed:deactivate'] + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('EXECUTE').and_return('1') + deactivate.execute + deactivate.reenable + + _output, status = run_verify + + expect(status).to be_nil + end + + it 'passes when no sub-keyed members exist' do + Fabricate(:member) + + _output, status = run_verify + + expect(status).to be_nil + end + end +end diff --git a/spec/services/subkeyed_member_cleanup_spec.rb b/spec/services/subkeyed_member_cleanup_spec.rb new file mode 100644 index 000000000..f378ff043 --- /dev/null +++ b/spec/services/subkeyed_member_cleanup_spec.rb @@ -0,0 +1,230 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SubkeyedMemberCleanup do + subject(:cleanup) { described_class.call(dry_run:) } + + let(:dry_run) { false } + let(:subkeyed_key) { 'aB3xK9mQ7vN2pR5sT8uW1yZ4cD6fG0hJ' } + let(:second_key) { 'zY9wV8uT7sR6qP5oN4mL3kJ2iH1gF0eD' } + + def create_subkeyed_member(key: subkeyed_key, name_value: 'Viktoriya Kravchenko', surname: 'Kravchenko', created_at: nil) + member = Member.new(email: key, name: name_value, surname:, + about_you: 'Created by the sub fallback', accepted_toc_at: Time.zone.now) + member.save(validate: false) + member.update_columns(created_at:) if created_at + member.auth_services.create!(provider: 'codebar', uid: key) + member + end + + # rubocop:disable Metrics/AbcSize -- table of fixture lambdas; one per OWNED_DATA relation + def owned_data_seeder + { + subscriptions: ->(member) { Fabricate(:subscription, member:) }, + workshop_invitations: ->(member) { Fabricate(:workshop_invitation, member:) }, + meeting_invitations: ->(member) { Fabricate(:meeting_invitation, member:) }, + invitations: ->(member) { Fabricate(:invitation, member:) }, + roles: ->(member) { member.add_role(:organiser, Fabricate(:chapter)) }, + bans: ->(member) { Fabricate(:ban, member:) }, + feedbacks: ->(member) { Fabricate(:feedback, coach: member) }, + member_notes: ->(member) { member.member_notes.create!(member:, author_id: member.id, note: 'x') } + } + end + # rubocop:enable Metrics/AbcSize + + def seed_owned_data(relation, member) + owned_data_seeder.fetch(relation).call(member) + end + + describe 'detection' do + let(:dry_run) { true } + + it 'flags a member whose email equals its codebar auth service uid' do + member = create_subkeyed_member + + result = cleanup + + expect(result.detected).to eq(1) + expect(result.deactivated).to eq(1) + expect(result.skipped).to eq(0) + expect(member.id).to be_present + end + + it 'flags a sub-keyed member whose name is a real name, not the key value' do + member = create_subkeyed_member(name_value: 'Andrew Steel') + + result = cleanup + + expect(result.detected).to eq(1) + expect(member.email).to eq(subkeyed_key) + end + + it 'never renames a member with a normal email' do + member = Fabricate(:member) + member.auth_services.create!(provider: 'codebar', uid: member.email) + + expect(cleanup.detected).to eq(0) + end + + it 'ignores a sub-keyed member created before the cutoff' do + create_subkeyed_member(created_at: described_class::CUTOFF_TIME - 1.day) + + expect(cleanup.detected).to eq(0) + end + + it 'ignores a sub-keyed email with no codebar auth service' do + member = Member.new(email: subkeyed_key, name: 'X', surname: 'Y', + about_you: 'x', accepted_toc_at: Time.zone.now) + member.save(validate: false) + + expect(cleanup.detected).to eq(0) + end + + it 'ignores members already handled: renamed by this cleanup or by the tooling it follows' do + create_subkeyed_member.tap { |m| m.update_columns(email: 'subkeyed.9.deactivated@codebar.io') } + .auth_services.update_all(uid: 'subkeyed.9.deactivated@codebar.io') + create_subkeyed_member.tap { |m| m.update_columns(email: 'duplicate.9.merged-into.8@codebar.io') } + .auth_services.update_all(uid: 'duplicate.9.merged-into.8@codebar.io') + create_subkeyed_member.tap { |m| m.update_columns(email: "deleted_user_#{Time.zone.now.to_fs(:number)}@codebar.io") } + .auth_services.update_all(uid: 'removed-manually') + + expect(cleanup.detected).to eq(0) + end + end + + describe 'deactivation' do + it 'removes auth services, renames the email, and adds an audit note with the original value' do + member = create_subkeyed_member + + result = cleanup + + member.reload + expect(result.deactivated).to eq(1) + expect(member.email).to eq("subkeyed.#{member.id}.deactivated@codebar.io") + expect(member.auth_services).to be_empty + note = MemberNote.find_by(member_id: member.id) + expect(note.note).to include("Original email/uid: #{subkeyed_key}") + expect(note.note).to match(/Deactivated \d{4}-\d{2}-\d{2}T\d{2}:\d{2}/) + expect(note.note).to include('Login will stay off until the fail-closed strategy guard ships') + expect(note.author_id).to eq(member.id) + end + + it 'is idempotent: a second run detects nothing and writes nothing' do + create_subkeyed_member + + cleanup + second = described_class.call(dry_run:) + + expect(second.detected).to eq(0) + expect(second.deactivated).to eq(0) + expect(MemberNote.count).to eq(1) + end + + context 'with dry_run' do + let(:dry_run) { true } + + it 'reports the deactivation without writing' do + member = create_subkeyed_member + + result = cleanup + + member.reload + expect(result.deactivated).to eq(1) + expect(member.email).to eq(subkeyed_key) + expect(member.auth_services.where(provider: 'codebar')).to exist + expect(MemberNote.where(member_id: member.id)).not_to exist + end + end + end + + describe 'skip rule (data ownership)' do + it 'skips a sub-keyed member that owns a subscription, never touching it' do + member = create_subkeyed_member + Fabricate(:subscription, member:) + + result = cleanup + + member.reload + expect(result.skipped).to eq(1) + expect(result.deactivated).to eq(0) + expect(member.email).to eq(subkeyed_key) + expect(member.auth_services.where(provider: 'codebar')).to exist + end + + it 'skips a sub-keyed member that owns roles, bans, feedbacks, or notes' do + member = create_subkeyed_member + member.add_role(:organiser, Fabricate(:chapter)) + + result = cleanup + + expect(result.skipped).to eq(1) + expect(member.reload.email).to eq(subkeyed_key) + end + + it 'skips a sub-keyed member that owns workshop invitations' do + member = create_subkeyed_member + Fabricate(:workshop_invitation, member:) + + result = cleanup + + expect(result.skipped).to eq(1) + end + + it 'skips for every relation in OWNED_DATA, so a wrong key fails the suite' do + described_class::OWNED_DATA.each_with_index do |(relation, _label), i| + key = "owned#{i}Key0123456789abcdefgh" + member = create_subkeyed_member(key:) + seed_owned_data(relation, member) + + result = cleanup + + expect(result.skipped).to eq(1), "OWNED_DATA relation #{relation} did not trigger the skip" + expect(member.reload.email).to eq(key) + end + end + + it 'counts skips as handled so a later verify can pass' do + member = create_subkeyed_member + Fabricate(:subscription, member:) + + result = cleanup + + expect(result.skipped).to eq(1) + expect(result.detected).to eq(1) + end + end + + describe 'mixing both outcomes' do + it 'deactivates only the data-free member and skips the data owner' do + create_subkeyed_member(name_value: 'Clean Case') + owner = create_subkeyed_member(key: second_key, name_value: 'Data Owner') + Fabricate(:subscription, member: owner) + + result = cleanup + + expect(result.detected).to eq(2) + expect(result.deactivated).to eq(1) + expect(result.skipped).to eq(1) + expect(Member.where('email LIKE ?', 'subkeyed.%.deactivated@codebar.io').count).to eq(1) + expect(owner.reload.email).to eq(second_key) + end + end + + describe 'exclusion chain (handled prefixes)' do + let(:dry_run) { true } + + it 'excludes a renamed member and keeps a live one: inclusion/exclusion control pair' do + excluded = create_subkeyed_member.tap do |member| + member.update_columns(email: 'duplicate.31452.merged-into.17284@codebar.io') + end + excluded.auth_services.update_all(uid: 'duplicate.31452.merged-into.17284@codebar.io') + kept = create_subkeyed_member(key: second_key) + + ids = described_class.new(dry_run: true).detectable_members.map(&:id) + + expect(ids).not_to include(excluded.id) + expect(ids).to include(kept.id) + end + end +end