fix(auth): reconcile smbCloud user by email when the auth_user id differs

find_or_create_from_smbcloud keyed only on smbcloud_id, so when smbCloud's auth_user id for an email differed from an older local record (e.g. a GitHub social login resolved to a different auth_user than the user's existing email/password record), it tried to create a duplicate User and failed on the unique email — surfacing as "An unexpected error occurred" on the GitHub callback (and would wedge email/password sign-in too). Now, when the smbcloud_id is unseen but the authenticated email already belongs to a local user, adopt that record and move it onto the new smbcloud_id (smbCloud is the identity authority). Adds model specs for the reconcile path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Seto Elkahfi committed Jun 30, 2026 at 21:16 UTC 1b415d7ee06f0c3641d1859cfde9058e1116c0de
2 files changed +61
app/models/user.rb
+15
index 40c8b9a..54cb9d2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -98,6 +98,21 @@ class User < ApplicationRecord user = find_or_initialize_by(smbcloud_id: smbcloud_id) + # smbCloud is the identity authority. If we've never seen this smbcloud_id + # but the authenticated email already belongs to a local user, adopt that + # record and move it onto the new smbcloud_id rather than failing on the + # unique email constraint. The smbCloud auth_user id for an email can change + # (e.g. the account was recreated, or a social login resolved to a different + # auth_user than an older local record), and this can otherwise wedge both + # GitHub and email/password sign-in for that user. + if user.new_record? && email.present? + existing = find_by(email: email) + if existing + user = existing + user.smbcloud_id = smbcloud_id + end + end + # Always sync email and token in case they changed on the auth server. user.email = email if email.present? user.access_token = access_token if access_token.present?
spec/models/user_smbcloud_spec.rb
+46
new file mode 100644 index 0000000..87f2919 --- /dev/null +++ b/spec/models/user_smbcloud_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe User, ".find_or_create_from_smbcloud" do + it "creates a new user when the smbcloud_id is unseen and the email is free" do + expect do + user = User.find_or_create_from_smbcloud({ id: 100, email: "new@example.com" }) + expect(user.smbcloud_id).to eq(100) + expect(user.email).to eq("new@example.com") + end.to change(User, :count).by(1) + end + + it "returns the same record for a known smbcloud_id" do + existing = User.create!(smbcloud_id: 200, email: "known@example.com", username: "known") + expect do + user = User.find_or_create_from_smbcloud({ id: 200, email: "known@example.com" }) + expect(user.id).to eq(existing.id) + end.not_to change(User, :count) + end + + # The regression: smbCloud's auth_user id for an email can differ from the id + # stored on an older local record (e.g. a social login resolves to a different + # auth_user). Keying only on smbcloud_id would try to create a duplicate and + # fail on the unique email, wedging sign-in. + it "adopts an existing user by email and moves it to the new smbcloud_id" do + existing = User.create!(smbcloud_id: 18, email: "person@example.com", username: "person") + + user = nil + expect do + user = User.find_or_create_from_smbcloud({ id: 86, email: "person@example.com" }) + end.not_to change(User, :count) + + expect(user.id).to eq(existing.id) + expect(user.smbcloud_id).to eq(86) + expect(user.username).to eq("person") # username preserved + expect(existing.reload.smbcloud_id).to eq(86) + end + + it "matches the existing email case-insensitively" do + User.create!(smbcloud_id: 18, email: "mixed@example.com", username: "mixed") + user = User.find_or_create_from_smbcloud({ id: 99, email: "MIXED@example.com" }) + expect(user.smbcloud_id).to eq(99) + expect(User.where("lower(email) = ?", "mixed@example.com").count).to eq(1) + end +end