Skip to content

Commit 5516708

Browse files
committed
Fixing various issues around name reservations
Users and Teams share a name reservation pool, and both had issues around name handling that either resulted simply in unique constraint violations or in a worse case a possibly inconsistent database. This commit improves the name validation and test coverage to make sure that everything is handled correctly, and that forms return proper error messages on failure.
1 parent 314799e commit 5516708

7 files changed

Lines changed: 221 additions & 6 deletions

File tree

app/controllers/verifications_controller.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def handle_register(token)
3030
metadata = JSON.parse(token.metadata || "{}") rescue {}
3131
desired_username = metadata["username"]
3232
user.username = desired_username
33+
user.skip_name_reservation = true
3334
if metadata["password_digest"].present?
3435
user.password_digest = metadata["password_digest"]
3536
end

app/models/team.rb

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ class Team < ApplicationRecord
1212

1313
validates :name, presence: true
1414
validates :name, format: { with: /\A[a-zA-Z0-9_\-\.]+\z/ }
15+
validate :name_available_in_reservations
1516

16-
after_commit :reserve_name, on: :create
17+
after_create :reserve_name
1718
after_destroy :release_name_reservation
1819

1920
def member?(user)
@@ -43,6 +44,17 @@ def mentionable_by?(user)
4344

4445
private
4546

47+
def name_available_in_reservations
48+
return if name.blank? || !will_save_change_to_name?
49+
50+
normalized = NameReservation.normalize(name)
51+
existing = NameReservation.find_by(name: normalized)
52+
return unless existing
53+
return if existing.owner_type == "Team" && existing.owner_id == id
54+
55+
errors.add(:name, "is already taken")
56+
end
57+
4658
def reserve_name
4759
NameReservation.reserve!(name:, owner: self)
4860
end

app/models/user.rb

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,23 +33,37 @@ def shares_team_with?(other_user)
3333
team_ids.intersect?(other_user.team_ids)
3434
end
3535

36+
attr_accessor :skip_name_reservation
37+
3638
validates :username, format: { with: /\A[a-zA-Z0-9_\-\.]+\z/, allow_blank: true }
39+
validates :username, uniqueness: { allow_blank: true, case_sensitive: false }
3740
validates :username, presence: true, on: :registration
41+
validate :username_available_in_reservations
3842

3943
before_save :release_old_username_reservation
40-
after_commit :reserve_username, on: [ :create, :update ]
44+
after_save :reserve_username
4145
after_destroy :release_name_reservation
4246

4347
private
4448

49+
def username_available_in_reservations
50+
return if username.blank? || !will_save_change_to_username? || skip_name_reservation
51+
52+
normalized = NameReservation.normalize(username)
53+
existing = NameReservation.find_by(name: normalized)
54+
return unless existing
55+
return if existing.owner_type == "User" && existing.owner_id == id
56+
57+
errors.add(:username, "is already taken")
58+
end
59+
4560
def release_old_username_reservation
4661
return unless will_save_change_to_username?
47-
old_username = username_before_last_save
48-
NameReservation.release_for(self) if old_username.present?
62+
NameReservation.release_for(self) if username_was.present?
4963
end
5064

5165
def reserve_username
52-
return if username.blank?
66+
return if username.blank? || skip_name_reservation
5367
NameReservation.reserve!(name: username, owner: self)
5468
end
5569

db/schema.rb

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

spec/models/team_spec.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,48 @@
5353
end
5454
end
5555

56+
describe "name reservation" do
57+
it "rejects name already reserved by a user" do
58+
create(:user, username: "claimed")
59+
60+
new_team = Team.new(name: "claimed")
61+
expect(new_team).not_to be_valid
62+
expect(new_team.errors[:name]).to include("is already taken")
63+
end
64+
65+
it "rejects name already reserved by another team" do
66+
Team.create!(name: "existing")
67+
68+
new_team = Team.new(name: "existing")
69+
expect(new_team).not_to be_valid
70+
expect(new_team.errors[:name]).to include("is already taken")
71+
end
72+
73+
it "rejects name case-insensitively" do
74+
create(:user, username: "CaseName")
75+
76+
new_team = Team.new(name: "casename")
77+
expect(new_team).not_to be_valid
78+
expect(new_team.errors[:name]).to include("is already taken")
79+
end
80+
81+
it "creates a reservation on create" do
82+
new_team = Team.create!(name: "freshteam")
83+
reservation = NameReservation.find_by(name: "freshteam")
84+
expect(reservation).to be_present
85+
expect(reservation.owner_type).to eq("Team")
86+
expect(reservation.owner_id).to eq(new_team.id)
87+
end
88+
89+
it "releases reservation on destroy" do
90+
new_team = Team.create!(name: "doomed")
91+
expect(NameReservation.find_by(name: "doomed")).to be_present
92+
93+
new_team.destroy!
94+
expect(NameReservation.find_by(name: "doomed")).to be_nil
95+
end
96+
end
97+
5698
describe "#mentionable_by?" do
5799
it "allows only members to mention private teams" do
58100
expect(team.mentionable_by?(user)).to be(true)

spec/models/user_spec.rb

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
RSpec.describe User, type: :model do
6+
describe "username validations" do
7+
it "rejects username already taken by another user" do
8+
user1 = create(:user, username: "taken")
9+
user2 = create(:user)
10+
11+
user2.username = "taken"
12+
expect(user2).not_to be_valid
13+
expect(user2.errors[:username]).to include("has already been taken")
14+
end
15+
16+
it "rejects username case-insensitively against other users" do
17+
create(:user, username: "TakenName")
18+
user2 = create(:user)
19+
20+
user2.username = "takenname"
21+
expect(user2).not_to be_valid
22+
expect(user2.errors[:username]).to include("has already been taken")
23+
end
24+
25+
it "rejects username already reserved by a team" do
26+
Team.create!(name: "teamname")
27+
user = create(:user)
28+
29+
user.username = "teamname"
30+
expect(user).not_to be_valid
31+
expect(user.errors[:username]).to include("is already taken")
32+
end
33+
34+
it "rejects username case-insensitively against teams" do
35+
Team.create!(name: "MyTeam")
36+
user = create(:user)
37+
38+
user.username = "myteam"
39+
expect(user).not_to be_valid
40+
expect(user.errors[:username]).to include("is already taken")
41+
end
42+
43+
it "allows setting a new unique username" do
44+
user = create(:user)
45+
expect(user.update(username: "unique_name")).to be(true)
46+
expect(NameReservation.find_by(name: "unique_name")).to be_present
47+
end
48+
49+
it "allows updating other attributes without affecting username reservation" do
50+
user = create(:user, username: "myname")
51+
expect(user.update(mention_restriction: :teammates_only)).to be(true)
52+
53+
reservation = NameReservation.find_by(name: "myname")
54+
expect(reservation).to be_present
55+
expect(reservation.owner_type).to eq("User")
56+
expect(reservation.owner_id).to eq(user.id)
57+
end
58+
59+
it "creates a name reservation when username is set" do
60+
user = create(:user)
61+
user.update!(username: "reserved_name")
62+
63+
reservation = NameReservation.find_by(name: "reserved_name")
64+
expect(reservation).to be_present
65+
expect(reservation.owner_type).to eq("User")
66+
expect(reservation.owner_id).to eq(user.id)
67+
end
68+
69+
it "releases old reservation and creates new one when username changes" do
70+
user = create(:user, username: "oldname")
71+
expect(NameReservation.find_by(name: "oldname")).to be_present
72+
73+
user.update!(username: "newname")
74+
expect(NameReservation.find_by(name: "oldname")).to be_nil
75+
expect(NameReservation.find_by(name: "newname")).to be_present
76+
end
77+
78+
it "rolls back username change if reservation fails" do
79+
user = create(:user, username: "original")
80+
Team.create!(name: "blocked")
81+
82+
expect(user.update(username: "blocked")).to be(false)
83+
user.reload
84+
expect(user.username).to eq("original")
85+
expect(NameReservation.find_by(name: "original")).to be_present
86+
end
87+
end
88+
end
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
RSpec.describe "Settings::Usernames", type: :request do
6+
def sign_in(user)
7+
al = create(:alias, user: user, email: "login-#{user.id}@example.com")
8+
user.person.update!(default_alias_id: al.id) if user.person.default_alias_id.nil?
9+
Alias.by_email(al.email).update_all(verified_at: Time.current)
10+
user.update!(password: "secret", password_confirmation: "secret") unless user.password_digest.present?
11+
post session_path, params: { email: al.email, password: "secret" }
12+
end
13+
14+
describe "PATCH /settings/username" do
15+
it "updates username successfully" do
16+
user = create(:user)
17+
sign_in(user)
18+
19+
patch settings_username_path, params: { user: { username: "new_username" } }
20+
expect(response).to redirect_to(settings_profile_path)
21+
expect(flash[:notice]).to eq("Username updated")
22+
expect(user.reload.username).to eq("new_username")
23+
end
24+
25+
it "shows error when username is taken by another user" do
26+
create(:user, username: "taken")
27+
user = create(:user, username: "original")
28+
sign_in(user)
29+
30+
patch settings_username_path, params: { user: { username: "taken" } }
31+
expect(response).to redirect_to(settings_profile_path)
32+
expect(flash[:alert]).to match(/already been taken/i)
33+
expect(user.reload.username).to eq("original")
34+
end
35+
36+
it "shows error when username is reserved by a team" do
37+
Team.create!(name: "devteam")
38+
user = create(:user, username: "original")
39+
sign_in(user)
40+
41+
patch settings_username_path, params: { user: { username: "devteam" } }
42+
expect(response).to redirect_to(settings_profile_path)
43+
expect(flash[:alert]).to match(/already taken/i)
44+
expect(user.reload.username).to eq("original")
45+
end
46+
47+
it "preserves old reservation when update is rejected" do
48+
Team.create!(name: "blocked")
49+
user = create(:user, username: "keeper")
50+
sign_in(user)
51+
52+
patch settings_username_path, params: { user: { username: "blocked" } }
53+
expect(user.reload.username).to eq("keeper")
54+
expect(NameReservation.find_by(name: "keeper")).to be_present
55+
end
56+
end
57+
end

0 commit comments

Comments
 (0)