diff --git a/app/assets/javascripts/discourse/app/components/invite-panel.js b/app/assets/javascripts/discourse/app/components/invite-panel.js index 7a77a6d2e8b..dd55ebeed29 100644 --- a/app/assets/javascripts/discourse/app/components/invite-panel.js +++ b/app/assets/javascripts/discourse/app/components/invite-panel.js @@ -317,6 +317,11 @@ export default Component.extend({ }); }, + @action + sendCloseModal() { + this.attrs.close(); + }, + @action createInvite() { if (this.disabled) { @@ -388,7 +393,7 @@ export default Component.extend({ }, @action - generateInvitelink() { + generateInviteLink() { if (this.disabled) { return; } diff --git a/app/assets/javascripts/discourse/app/templates/components/invite-panel.hbs b/app/assets/javascripts/discourse/app/templates/components/invite-panel.hbs index d5cfd1e3494..f78860493dc 100644 --- a/app/assets/javascripts/discourse/app/templates/components/invite-panel.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/invite-panel.hbs @@ -87,7 +87,7 @@ {{#if inviteModel.finished}} {{d-button class="btn-primary" - action=(route-action "closeModal") + action=(action "sendCloseModal") label="close"}} {{else}} {{d-button @@ -99,7 +99,7 @@ {{#if showCopyInviteButton}} {{d-button icon="link" - action=(action "generateInvitelink") + action=(action "generateInviteLink") class="btn-primary generate-invite-link" disabled=disabledCopyLink label="user.invited.generate_link"}} diff --git a/app/assets/javascripts/discourse/tests/integration/components/invite-panel-test.js b/app/assets/javascripts/discourse/tests/integration/components/invite-panel-test.js new file mode 100644 index 00000000000..8d161d20226 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/invite-panel-test.js @@ -0,0 +1,56 @@ +import { set } from "@ember/object"; +import { click, fillIn } from "@ember/test-helpers"; +import User from "discourse/models/user"; +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import pretender from "discourse/tests/helpers/create-pretender"; +import { + discourseModule, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; +import selectKit from "discourse/tests/helpers/select-kit-helper"; +import hbs from "htmlbars-inline-precompile"; + +discourseModule("Integration | Component | invite-panel", function (hooks) { + setupRenderingTest(hooks); + + componentTest("shows the invite link after it is generated", { + template: hbs`{{invite-panel panel=panel}}`, + + beforeEach() { + pretender.get("/u/search/users", () => { + return [200, { "Content-Type": "application/json" }, { users: [] }]; + }); + + pretender.post("/invites", () => { + return [ + 200, + { "Content-Type": "application/json" }, + { + link: "http://example.com/invites/92c297e886a0ca03089a109ccd6be155", + }, + ]; + }); + + set(this.currentUser, "details", { can_invite_via_email: true }); + this.set("panel", { + id: "invite", + model: { inviteModel: User.create(this.currentUser) }, + }); + }, + + async test(assert) { + const input = selectKit(".invite-user-input"); + await input.expand(); + await fillIn(".invite-user-input .filter-input", "eviltrout@example.com"); + await input.selectRowByValue("eviltrout@example.com"); + assert.ok(queryAll(".send-invite:disabled").length === 0); + await click(".generate-invite-link"); + assert.equal( + find(".invite-link-input")[0].value, + "http://example.com/invites/92c297e886a0ca03089a109ccd6be155" + ); + }, + }); +}); diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index ed4ee4d59b5..0e6a2b4f2b6 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -247,7 +247,6 @@ class InvitesController < ApplicationController raise Discourse::InvalidParameters.new(:email) if invite.blank? invite.resend_invite render json: success_json - rescue RateLimiter::LimitExceeded render_json_error(I18n.t("rate_limiter.slow_down")) end diff --git a/app/models/invite.rb b/app/models/invite.rb index 749bb8512c8..109b767c535 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -70,7 +70,7 @@ class Invite < ActiveRecord::Base end def redeemable? - !redeemed? && !expired? && !destroyed? && link_valid? + !redeemed? && !expired? && !deleted_at? && !destroyed? && link_valid? end def redeemed? diff --git a/spec/models/invite_spec.rb b/spec/models/invite_spec.rb index bda281c9309..35875a3fde2 100644 --- a/spec/models/invite_spec.rb +++ b/spec/models/invite_spec.rb @@ -3,194 +3,90 @@ require 'rails_helper' describe Invite do + fab!(:user) { Fabricate(:user) } - it { is_expected.to validate_presence_of :invited_by_id } + context 'validators' do + it { is_expected.to validate_presence_of :invited_by_id } + it { is_expected.to rate_limit } - it { is_expected.to rate_limit } - - let(:iceking) { 'iceking@adventuretime.ooo' } - - context 'user validators' do - fab!(:coding_horror) { Fabricate(:coding_horror) } - fab!(:user) { Fabricate(:user) } - let(:invite) { Invite.create(email: user.email, invited_by: coding_horror) } - - it "should not allow an invite with the same email as an existing user" do - expect(invite).not_to be_valid - end - - it "should not allow a user to invite themselves" do - expect(invite.email_already_exists).to eq(true) - end - end - - context 'email validators' do - fab!(:coding_horror) { Fabricate(:coding_horror) } - - it "should not allow an invite with unformatted email address" do - invite = Fabricate.build(:invite, email: "John Doe <john.doe@example.com>") - expect(invite.valid?).to eq(false) - expect(invite.errors.details[:email].first[:error]).to eq(I18n.t("user.email.invalid")) - end - - it "should not allow an invite with blocklisted email" do - invite = Invite.create(email: "test@mailinator.com", invited_by: coding_horror) - expect(invite).not_to be_valid - end - - it "should allow an invite with non-blocklisted email" do - invite = Fabricate(:invite, email: "test@mail.com", invited_by: coding_horror) + it 'allows invites with valid emails' do + invite = Fabricate.build(:invite, email: 'test@example.com', invited_by: user) expect(invite).to be_valid end - it "should not allow an invalid email address" do + it 'does not allow invites with invalid emails' do + invite = Fabricate.build(:invite, email: 'John Doe <john.doe@example.com>') + expect(invite.valid?).to eq(false) + expect(invite.errors.details[:email].first[:error]).to eq(I18n.t('user.email.invalid')) + end + + it 'does not allow an invite with the same email as an existing user' do + invite = Fabricate.build(:invite, email: Fabricate(:user).email, invited_by: user) + expect(invite).not_to be_valid + + invite = Fabricate.build(:invite, email: user.email, invited_by: user) + expect(invite).not_to be_valid + end + + it 'does not allow an invite with blocked email' do + invite = Invite.create(email: 'test@mailinator.com', invited_by: user) + expect(invite).not_to be_valid + end + + it 'does not allow an invalid email address' do invite = Fabricate.build(:invite, email: 'asjdso') expect(invite.valid?).to eq(false) - expect(invite.errors.details[:email].first[:error]).to eq(I18n.t("user.email.invalid")) + expect(invite.errors.details[:email].first[:error]).to eq(I18n.t('user.email.invalid')) end end - context '#create' do - context 'saved' do - subject { Fabricate(:invite) } + context '::generate' do + it 'saves an invites' do + invite = Invite.generate(user, email: 'TEST@EXAMPLE.COM') + expect(invite.invite_key).to be_present + expect(invite.email).to eq('test@example.com') + end - it "works" do - expect(subject.invite_key).to be_present - expect(subject.email_already_exists).to eq(false) + it 'can succeed for staged users emails' do + Fabricate(:staged, email: 'test@example.com') + invite = Invite.generate(user, email: 'test@example.com') + expect(invite.email).to eq('test@example.com') + end + + it 'raises an error when inviting an existing user' do + expect { Invite.generate(user, email: user.email) } + .to raise_error(Invite::UserExists) + end + + context 'via email' do + it 'enqueues a job to email the invite' do + invite = Invite.generate(user, email: 'test@example.com') + expect(invite.emailed_status).to eq(Invite.emailed_status_types[:sending]) + expect(Jobs::InviteEmail.jobs.size).to eq(1) end - it 'should store a lower case version of the email' do - expect(subject.email).to eq(iceking) + it 'can skip the job to email the invite' do + invite = Invite.generate(user, email: 'test@example.com', skip_email: true) + expect(invite.emailed_status).to eq(Invite.emailed_status_types[:not_required]) + expect(Jobs::InviteEmail.jobs.size).to eq(0) + end + + it 'can invite the same user after their invite was destroyed' do + Invite.generate(user, email: 'test@example.com').destroy! + invite = Invite.generate(user, email: 'test@example.com') + expect(invite).to be_present end end - context 'to a topic' do - fab!(:topic) { Fabricate(:topic) } - let(:inviter) { topic.user } - - context 'email' do - it 'enqueues a job to email the invite' do - expect do - Invite.generate(inviter, email: iceking, topic: topic) - end.to change { Jobs::InviteEmail.jobs.size } - end + context 'via link' do + it 'does not enqueue a job to email the invite' do + invite = Invite.generate(user, skip_email: true) + expect(invite.emailed_status).to eq(Invite.emailed_status_types[:not_required]) + expect(Jobs::InviteEmail.jobs.size).to eq(0) end - context 'links' do - it 'does not enqueue a job to email the invite' do - expect { Invite.generate(inviter, email: iceking, topic: topic, skip_email: true) } - .not_to change { Jobs::InviteEmail.jobs.size } - end - end - - context 'destroyed' do - it "can invite the same user after their invite was destroyed" do - Invite.generate(inviter, email: iceking, topic: topic).destroy! - invite = Invite.generate(inviter, email: iceking, topic: topic) - expect(invite).to be_present - end - end - - context 'after created' do - let(:invite) { Invite.generate(inviter, email: iceking, topic: topic) } - - it 'belongs to the topic' do - expect(topic.invites).to eq([invite]) - expect(invite.topics).to eq([topic]) - end - - context 'when added by another user' do - fab!(:coding_horror) { Fabricate(:coding_horror) } - - let(:new_invite) do - Invite.generate(coding_horror, email: iceking, topic: topic) - end - - it 'returns a different invite' do - expect(new_invite).not_to eq(invite) - expect(new_invite.invite_key).not_to eq(invite.invite_key) - expect(new_invite.topics).to eq([topic]) - end - end - - context 'when adding a duplicate' do - it 'returns the original invite' do - %w{ - iceking@adventuretime.ooo - iceking@ADVENTURETIME.ooo - ICEKING@adventuretime.ooo - }.each do |email| - expect(Invite.generate(inviter, email: email, topic: topic)).to eq(invite) - end - end - - it 'updates timestamp of existing invite' do - freeze_time - invite.update!(created_at: 10.days.ago) - - resend_invite = Invite.generate(inviter, email: 'iceking@adventuretime.ooo', topic: topic) - - expect(resend_invite.created_at).to eq_time(Time.zone.now) - end - - it 'returns a new invite if the other has expired' do - SiteSetting.invite_expiry_days = 1 - invite.update!(expires_at: 2.days.ago) - - new_invite = Invite.generate(inviter, email: 'iceking@adventuretime.ooo', topic: topic) - expect(new_invite).not_to eq(invite) - expect(new_invite).not_to be_expired - end - end - - context 'when adding to another topic' do - fab!(:another_topic) { Fabricate(:topic, user: topic.user) } - - it 'should be the same invite' do - new_invite = Invite.generate(inviter, email: iceking, topic: another_topic) - expect(new_invite).to eq(invite) - expect(another_topic.invites).to eq([invite]) - expect(invite.topics).to match_array([topic, another_topic]) - end - end - - it 'resets expiry of a resent invite' do - SiteSetting.invite_expiry_days = 2 - invite.update!(invalidated_at: 10.days.ago, expires_at: 10.days.ago) - expect(invite).to be_expired - - invite.resend_invite - expect(invite.invalidated_at).to be_nil - expect(invite).not_to be_expired - end - - it 'correctly marks invite emailed_status for email invites' do - expect(invite.emailed_status).to eq(Invite.emailed_status_types[:sending]) - - Invite.generate(inviter, email: iceking, topic: topic) - expect(invite.reload.emailed_status).to eq(Invite.emailed_status_types[:sending]) - end - - it 'does not mark emailed_status as sending after generating invite link' do - expect(invite.emailed_status).to eq(Invite.emailed_status_types[:sending]) - - Invite.generate(inviter, email: iceking, topic: topic, emailed_status: Invite.emailed_status_types[:not_required]) - expect(invite.reload.emailed_status).to eq(Invite.emailed_status_types[:not_required]) - - Invite.generate(inviter, email: iceking, topic: topic) - expect(invite.reload.emailed_status).to eq(Invite.emailed_status_types[:not_required]) - - Invite.generate(inviter, email: iceking, topic: topic, emailed_status: Invite.emailed_status_types[:not_required]) - expect(invite.reload.emailed_status).to eq(Invite.emailed_status_types[:not_required]) - end - end - end - - context 'invite links' do - let(:inviter) { Fabricate(:user) } - - it "can be created" do - invite = Invite.generate(inviter, max_redemptions_allowed: 5) + it 'can be created' do + invite = Invite.generate(user, max_redemptions_allowed: 5) expect(invite.max_redemptions_allowed).to eq(5) expect(invite.expires_at.to_date).to eq(SiteSetting.invite_expiry_days.days.from_now.to_date) expect(invite.emailed_status).to eq(Invite.emailed_status_types[:not_required]) @@ -198,342 +94,285 @@ describe Invite do end it 'checks for max_redemptions_allowed range' do - SiteSetting.invite_link_max_redemptions_limit = 1000 - expect { Invite.generate(inviter, max_redemptions_allowed: 1001) } - .to raise_error(ActiveRecord::RecordInvalid) + SiteSetting.invite_link_max_redemptions_limit_users = 3 + expect { Invite.generate(user, max_redemptions_allowed: 4) }.to raise_error(ActiveRecord::RecordInvalid) + + SiteSetting.invite_link_max_redemptions_limit = 3 + expect { Invite.generate(Fabricate(:admin), max_redemptions_allowed: 4) }.to raise_error(ActiveRecord::RecordInvalid) + end + end + + context 'when sending an invite to the same user' do + fab!(:invite) { Invite.generate(user, email: 'test@example.com') } + + it 'returns the original invite' do + %w{test@EXAMPLE.com TEST@example.com}.each do |email| + expect(Invite.generate(user, email: email)).to eq(invite) + end end - it 'does not enqueue a job to email the invite' do - expect { Invite.generate(inviter) } - .not_to change { Jobs::InviteEmail.jobs.size } + it 'updates timestamp of existing invite' do + freeze_time + invite.update!(created_at: 10.days.ago) + resend_invite = Invite.generate(user, email: 'test@example.com') + expect(resend_invite).to eq(invite) + expect(resend_invite.created_at).to eq_time(Time.zone.now) + end + + it 'returns a new invite if the other has expired' do + SiteSetting.invite_expiry_days = 1 + invite.update!(expires_at: 2.days.ago) + + new_invite = Invite.generate(user, email: 'test@example.com') + expect(new_invite).not_to eq(invite) + expect(new_invite).not_to be_expired + end + + it 'returns a new invite when invited by a different user' do + invite = Invite.generate(user, email: 'test@example.com') + expect(invite.email).to eq('test@example.com') + + another_invite = Invite.generate(Fabricate(:user), email: 'test@example.com') + expect(another_invite.email).to eq('test@example.com') + + expect(invite.invite_key).not_to eq(another_invite.invite_key) + end + end + + context 'invite to a topic' do + fab!(:topic) { Fabricate(:topic) } + let(:invite) { Invite.generate(topic.user, email: 'test@example.com', topic: topic) } + + it 'belongs to the topic' do + expect(topic.invites).to contain_exactly(invite) + expect(invite.topics).to contain_exactly(topic) + end + + context 'when adding to another topic' do + fab!(:another_topic) { Fabricate(:topic, user: topic.user) } + + it 'should be the same invite' do + new_invite = Invite.generate(topic.user, email: 'test@example.com', topic: another_topic) + expect(invite).to eq(new_invite) + expect(invite.topics).to contain_exactly(topic, another_topic) + expect(topic.invites).to contain_exactly(invite) + expect(another_topic.invites).to contain_exactly(invite) + end end end end - context 'an existing user' do - fab!(:topic) { Fabricate(:topic, category_id: nil, archetype: 'private_message') } - fab!(:coding_horror) { Fabricate(:coding_horror) } - - it "raises the right error" do - expect { Invite.generate(topic.user, email: coding_horror.email, topic: topic) } - .to raise_error(Invite::UserExists) - end - end - - context 'a staged user' do - it 'creates an invite for a staged user' do - Fabricate(:staged, email: 'staged@account.com') - invite = Invite.generate(Fabricate(:coding_horror), email: 'staged@account.com') - - expect(invite).to be_valid - expect(invite.email).to eq('staged@account.com') - end - end - - context '.redeem' do - + context '#redeem' do fab!(:invite) { Fabricate(:invite) } + it 'works' do + user = invite.redeem + expect(invite.invited_users.map(&:user)).to contain_exactly(user) + expect(user.is_a?(User)).to eq(true) + expect(user.trust_level).to eq(SiteSetting.default_invitee_trust_level) + expect(user.send_welcome_message).to eq(true) + + expect(invite.reload.redemption_count).to eq(1) + expect(invite.redeem).to be_blank + end + it 'creates a notification for the invitee' do - expect { invite.redeem }.to change(Notification, :count) + expect { invite.redeem }.to change { Notification.count } end - it 'wont redeem an expired invite' do - SiteSetting.invite_expiry_days = 10 - invite.update_column(:expires_at, 20.days.ago) + it 'does not work with expired invites' do + invite.update!(expires_at: 1.day.ago) expect(invite.redeem).to be_blank end - it 'wont redeem a deleted invite' do - invite.destroy + it 'does not work with deleted invites' do + invite.trash! expect(invite.redeem).to be_blank end - it "won't redeem an invalidated invite" do - invite.invalidated_at = 1.day.ago + it 'does not work with deleted invites' do + invite.destroy! expect(invite.redeem).to be_blank end - context "deletes duplicate invites" do - fab!(:another_user) { Fabricate(:user) } - - it 'delete duplicate invite' do - another_invite = Fabricate(:invite, email: invite.email, invited_by: another_user) - invite.redeem - duplicate_invite = Invite.find_by(id: another_invite.id) - expect(duplicate_invite).to be_nil - end - - it 'does not delete already redeemed invite' do - redeemed_invite = Fabricate(:invite, email: invite.email, invited_by: another_user) - Fabricate(:invited_user, invite: invite, user: Fabricate(:user)) - invite.redeem - used_invite = Invite.find_by(id: redeemed_invite.id) - expect(used_invite).not_to be_nil - end + it 'does not work with invalidated invites' do + invite.update(invalidated_at: 1.day.ago) + expect(invite.redeem).to be_blank end - context "as a moderator" do - it "will give the user a moderator flag" do - invite.invited_by = Fabricate(:admin) - invite.moderator = true - invite.save + it 'deletes duplicate invite' do + another_invite = Fabricate(:invite, email: invite.email, invited_by: Fabricate(:user)) + another_redeemed_invite = Fabricate(:invite, email: invite.email, invited_by: Fabricate(:user)) + Fabricate(:invited_user, invite: another_redeemed_invite) + + user = invite.redeem + expect(user).not_to eq(nil) + expect(Invite.find_by(id: another_invite.id)).to eq(nil) + expect(Invite.find_by(id: another_redeemed_invite.id)).not_to eq(nil) + end + + context 'as a moderator' do + it 'will give the user a moderator flag' do + invite.update!(moderator: true, invited_by: Fabricate(:admin)) user = invite.redeem expect(user).to be_moderator end - it "will not give the user a moderator flag if the inviter is not staff" do - invite.moderator = true - invite.save + it 'will not give the user a moderator flag if the inviter is not staff' do + invite.update!(moderator: true) user = invite.redeem expect(user).not_to be_moderator end end - context "when inviting to groups" do - it "add the user to the correct groups" do + context 'when inviting to groups' do + it 'add the user to the correct groups' do group = Fabricate(:group) group.add_owner(invite.invited_by) - invite.invited_groups.build(group_id: group.id) - invite.save + invite.invited_groups.create!(group_id: group.id) user = invite.redeem - expect(user.groups.count).to eq(1) + expect(user.groups).to contain_exactly(group) end end - context "invite trust levels" do - it "returns the trust level in default_invitee_trust_level" do - SiteSetting.default_invitee_trust_level = TrustLevel[3] - expect(invite.redeem.trust_level).to eq(TrustLevel[3]) - end + it 'activates user when must_approve_users? is enabled' do + SiteSetting.must_approve_users = true + invite.invited_by = Fabricate(:admin) + + user = invite.redeem + expect(user.approved?).to eq(true) end - context 'inviting when must_approve_users? is enabled' do - it 'correctly activates accounts' do - invite.invited_by = Fabricate(:admin) - SiteSetting.must_approve_users = true - user = invite.redeem - expect(user.approved?).to eq(true) - end - end + context 'invite to a topic' do + fab!(:topic) { Fabricate(:private_message_topic) } + fab!(:another_topic) { Fabricate(:private_message_topic) } - context 'simple invite' do - let!(:user) { invite.redeem } - - it 'works correctly' do - expect(user.is_a?(User)).to eq(true) - expect(user.send_welcome_message).to eq(true) - expect(user.trust_level).to eq(SiteSetting.default_invitee_trust_level) + before do + invite.topic_invites.create!(topic: topic) end - context 'after redeeming' do - before do - invite.reload - end - - it 'works correctly' do - # has set the user_id attribute - expect(invite.invited_users.first.user).to eq(user) - - # returns true for redeemed - expect(invite).to be_redeemed - end - - context 'again' do - it 'will not redeem twice' do - expect(invite.redeem).to be_blank - end - end - end - end - - context 'invited to topics' do - fab!(:tl2_user) { Fabricate(:user, trust_level: 2) } - fab!(:topic) { Fabricate(:private_message_topic, user: tl2_user) } - - let!(:invite) do - topic.invite(topic.user, 'jake@adventuretime.ooo') - Invite.find_by(invited_by_id: topic.user) - end - - context 'redeem topic invite' do - it 'adds the user to the topic_users' do - user = invite.redeem - topic.reload - expect(topic.allowed_users.include?(user)).to eq(true) - expect(Guardian.new(user).can_see?(topic)).to eq(true) - end - end - - context 'invited by another user to the same topic' do - fab!(:another_tl2_user) { Fabricate(:user, trust_level: 2) } - let!(:another_invite) { topic.invite(another_tl2_user, 'jake@adventuretime.ooo') } - let!(:user) { invite.redeem } - - it 'adds the user to the topic_users' do - topic.reload - expect(topic.allowed_users.include?(user)).to eq(true) - end - end - - context 'invited by another user to a different topic' do - let!(:user) { invite.redeem } - fab!(:another_tl2_user) { Fabricate(:user, trust_level: 2) } - fab!(:another_topic) { Fabricate(:topic, user: another_tl2_user) } - - it 'adds the user to the topic_users of the first topic' do - expect(another_topic.invite(another_tl2_user, user.username)).to be_truthy # invited via username - expect(topic.allowed_users.include?(user)).to eq(true) - end - end - end - - context 'invite_link' do - fab!(:invite_link) { Fabricate(:invite, email: nil, max_redemptions_allowed: 5, expires_at: 1.month.from_now, emailed_status: Invite.emailed_status_types[:not_required]) } - - it 'works correctly' do - user = invite_link.redeem(email: 'foo@example.com') - expect(user.is_a?(User)).to eq(true) - expect(user.send_welcome_message).to eq(true) - expect(user.trust_level).to eq(SiteSetting.default_invitee_trust_level) - expect(user.active).to eq(false) - expect(invite_link.reload.redemption_count).to eq(1) - end - - it 'returns error if user with that email already exists' do - user = Fabricate(:user) - expect { invite_link.redeem(email: user.email) }.to raise_error(Invite::UserExists) + it 'adds the user to topic_users' do + invited_user = invite.redeem + expect(invited_user).not_to eq(nil) + expect(topic.reload.allowed_users.include?(invited_user)).to eq(true) + expect(Guardian.new(invited_user).can_see?(topic)).to eq(true) end end end - describe '.pending' do - context 'with user that has invited' do - it 'returns invites' do - inviter = Fabricate(:user) - invite = Fabricate(:invite, invited_by: inviter) - - expect(Invite.pending(inviter)).to include(invite) - end - end - - context 'with user that has not invited' do - it 'does not return invites' do - user = Fabricate(:user) - Fabricate(:invite) - - expect(Invite.pending(user)).to be_empty - end - end - - it 'returns pending invites only' do - inviter = Fabricate(:user) - - redeemed_invite = Fabricate(:invite, invited_by: inviter, email: 'redeemed@example.com') - redeemed_invite.redeem - - pending_invite = Fabricate(:invite, invited_by: inviter, email: 'pending@example.com') - pending_link_invite = Fabricate(:invite, invited_by: inviter, max_redemptions_allowed: 5) - - expired_invite = Fabricate(:invite, invited_by: inviter, email: 'expired@example.com', expires_at: 1.day.ago) - - expect(Invite.pending(inviter)).to contain_exactly(pending_invite, pending_link_invite) - end - end - - describe '.redeemed_users' do - it 'returns redeemed invites only' do - inviter = Fabricate(:user) - - Fabricate(:invite, invited_by: inviter, email: 'pending@example.com') - - redeemed_invite = Fabricate(:invite, invited_by: inviter, email: 'redeemed@example.com') - Fabricate(:invited_user, invite: redeemed_invite, user: Fabricate(:user)) - - expect(Invite.redeemed_users(inviter)).to contain_exactly(redeemed_invite.invited_users.first) - end - - it 'returns redeemed invites even if trashed' do - inviter = Fabricate(:user) - redeemed_invite = Fabricate(:invite, invited_by: inviter, email: 'redeemed@example.com') - Fabricate(:invited_user, invite: redeemed_invite, user: Fabricate(:user)) - redeemed_invite.trash! - - expect(Invite.redeemed_users(inviter)).to contain_exactly(redeemed_invite.invited_users.first) - end - - it 'returns redeemed invites for invite links' do - inviter = Fabricate(:user) - invite_link = Fabricate(:invite, invited_by: inviter, max_redemptions_allowed: 5) - - redeemed = [ - Fabricate(:invited_user, invite: invite_link, user: Fabricate(:user)), - Fabricate(:invited_user, invite: invite_link, user: Fabricate(:user)), - Fabricate(:invited_user, invite: invite_link, user: Fabricate(:user)) - ] - - expect(Invite.redeemed_users(inviter)).to match_array(redeemed) - end - end - - describe '.invalidate_for_email' do - let(:email) { 'invite.me@example.com' } - subject { described_class.invalidate_for_email(email) } - - it 'returns nil if there is no invite for the given email' do - expect(subject).to eq(nil) - end - - it 'sets the matching invite to be invalid' do - invite = Fabricate(:invite, invited_by: Fabricate(:user), email: email) - expect(subject).to eq(invite) - expect(subject.link_valid?).to eq(false) - expect(subject).to be_valid - end - - it 'sets the matching invite to be invalid without being case-sensitive' do - invite = Fabricate(:invite, invited_by: Fabricate(:user), email: 'invite.me2@Example.COM') - result = described_class.invalidate_for_email('invite.me2@EXAMPLE.com') - expect(result).to eq(invite) - expect(result.link_valid?).to eq(false) - expect(result).to be_valid - end - end - - describe '.redeem_from_email' do - fab!(:inviter) { Fabricate(:user) } - fab!(:invite) { Fabricate(:invite, invited_by: inviter, email: 'test@example.com') } + describe '#redeem_from_email' do + fab!(:invite) { Fabricate(:invite, email: 'test@example.com') } fab!(:user) { Fabricate(:user, email: invite.email) } it 'redeems the invite from email' do Invite.redeem_from_email(user.email) - invite.reload - expect(invite).to be_redeemed + expect(invite.reload).to be_redeemed end it 'does not redeem the invite if email does not match' do - Invite.redeem_from_email('test24@example.com') - invite.reload - expect(invite).not_to be_redeemed + Invite.redeem_from_email('test2@example.com') + expect(invite.reload).not_to be_redeemed end end - describe '#emailed_status_types' do - context "verify enum sequence" do - before do - @emailed_status_types = Invite.emailed_status_types + context 'scopes' do + fab!(:inviter) { Fabricate(:user) } + + fab!(:pending_invite) { Fabricate(:invite, invited_by: inviter, email: 'pending@example.com') } + fab!(:pending_link_invite) { Fabricate(:invite, invited_by: inviter, max_redemptions_allowed: 5) } + fab!(:pending_invite_from_another_user) { Fabricate(:invite) } + + fab!(:expired_invite) { Fabricate(:invite, invited_by: inviter, email: 'expired@example.com', expires_at: 1.day.ago) } + + fab!(:redeemed_invite) { Fabricate(:invite, invited_by: inviter, email: 'redeemed@example.com') } + let!(:redeemed_invite_user) { redeemed_invite.redeem } + + fab!(:partially_redeemed_invite) { Fabricate(:invite, invited_by: inviter, email: nil, max_redemptions_allowed: 5) } + let!(:partially_redeemed_invite_user) { partially_redeemed_invite.redeem(email: 'partially_redeemed_invite@example.com') } + + fab!(:redeemed_and_expired_invite) { Fabricate(:invite, invited_by: inviter, email: 'redeemed_and_expired@example.com') } + let!(:redeemed_and_expired_invite_user) do + user = redeemed_and_expired_invite.redeem + redeemed_and_expired_invite.update!(expires_at: 1.day.ago) + user + end + + fab!(:partially_redeemed_and_expired_invite) { Fabricate(:invite, invited_by: inviter, email: nil, max_redemptions_allowed: 5) } + let!(:partially_redeemed_and_expired_invite_user) do + user = partially_redeemed_and_expired_invite.redeem(email: 'partially_redeemed_and_expired_invite@example.com') + partially_redeemed_and_expired_invite.update!(expires_at: 1.day.ago) + user + end + + describe '#pending' do + it 'returns pending invites only' do + expect(Invite.pending(inviter)).to contain_exactly( + pending_invite, pending_link_invite, partially_redeemed_invite + ) + end + end + + describe '#expired' do + it 'returns expired invites only' do + expect(Invite.expired(inviter)).to contain_exactly( + expired_invite, partially_redeemed_and_expired_invite + ) + end + end + + describe '#redeemed_users' do + it 'returns redeemed users' do + expect(Invite.redeemed_users(inviter).map(&:user)).to contain_exactly( + redeemed_invite_user, partially_redeemed_invite_user, redeemed_and_expired_invite_user, partially_redeemed_and_expired_invite_user + ) end - it "'not_required' should be at 0 position" do - expect(@emailed_status_types[:not_required]).to eq(0) - end + it 'returns redeemed users for trashed invites' do + [redeemed_invite, partially_redeemed_invite, redeemed_and_expired_invite, partially_redeemed_and_expired_invite].each(&:trash!) - it "'sent' should be at 4th position" do - expect(@emailed_status_types[:sent]).to eq(4) + expect(Invite.redeemed_users(inviter).map(&:user)).to contain_exactly( + redeemed_invite_user, partially_redeemed_invite_user, redeemed_and_expired_invite_user, partially_redeemed_and_expired_invite_user + ) end end end + + describe '#invalidate_for_email' do + it 'returns nil if there is no invite for the given email' do + invite = Invite.invalidate_for_email('test@example.com') + expect(invite).to eq(nil) + end + + it 'sets the matching invite to be invalid' do + invite = Fabricate(:invite, invited_by: Fabricate(:user), email: 'test@example.com') + result = Invite.invalidate_for_email('test@example.com') + + expect(result).to eq(invite) + expect(result.link_valid?).to eq(false) + end + + it 'sets the matching invite to be invalid without being case-sensitive' do + invite = Fabricate(:invite, invited_by: Fabricate(:user), email: 'test@Example.COM') + result = Invite.invalidate_for_email('test@EXAMPLE.com') + + expect(result).to eq(invite) + expect(result.link_valid?).to eq(false) + end + end + + describe '#resend_email' do + fab!(:invite) { Fabricate(:invite) } + + it 'resets expiry of a resent invite' do + invite.update!(invalidated_at: 10.days.ago, expires_at: 10.days.ago) + expect(invite).to be_expired + + invite.resend_invite + expect(invite).not_to be_expired + expect(invite.invalidated_at).to be_nil + end + end end diff --git a/spec/requests/invites_controller_spec.rb b/spec/requests/invites_controller_spec.rb index 8fd37b887ff..53645fb52da 100644 --- a/spec/requests/invites_controller_spec.rb +++ b/spec/requests/invites_controller_spec.rb @@ -4,277 +4,229 @@ require 'rails_helper' describe InvitesController do fab!(:admin) { Fabricate(:admin) } - fab!(:trust_level_4) { Fabricate(:trust_level_4) } + fab!(:user) { Fabricate(:user, trust_level: SiteSetting.min_trust_level_to_allow_invite) } - context 'show' do + context '#show' do fab!(:invite) { Fabricate(:invite) } - fab!(:user) { Fabricate(:coding_horror) } - it 'does not work for logged in users' do - sign_in(Fabricate(:user)) + it 'shows the accept invite page' do get "/invites/#{invite.invite_key}" - expect(response.status).to eq(200) - body = response.body - expect(CGI.unescapeHTML(body)).to include(I18n.t("login.already_logged_in")) + expect(response.body).to have_tag(:script, with: { src: '/assets/application.js' }) + expect(response.body).to include('i*****g@a***********e.ooo') + expect(response.body).not_to include(invite.email) + expect(response.body).to_not include(I18n.t('invite.not_found_template', site_name: SiteSetting.title, base_url: Discourse.base_url)) end - it "returns error if invite not found" do - get "/invites/nopeNOPEnope" - + it 'shows unobfuscated email if email data is present in authentication data' do + ActionDispatch::Request.any_instance.stubs(:session).returns(authentication: { email: invite.email }) + get "/invites/#{invite.invite_key}" expect(response.status).to eq(200) - - body = response.body - expect(body).to_not have_tag(:script, with: { src: '/assets/application.js' }) - expect(CGI.unescapeHTML(body)).to include(I18n.t('invite.not_found', base_url: Discourse.base_url)) + expect(response.body).to have_tag(:script, with: { src: '/assets/application.js' }) + expect(response.body).to include(invite.email) + expect(response.body).not_to include('i*****g@a***********e.ooo') end - it "returns error if invite expired" do + it 'fails for logged in users' do + sign_in(Fabricate(:user)) + + get "/invites/#{invite.invite_key}" + expect(response.status).to eq(200) + expect(response.body).to_not have_tag(:script, with: { src: '/assets/application.js' }) + expect(response.body).to include(I18n.t('login.already_logged_in')) + end + + it 'fails if invite does not exist' do + get '/invites/missing' + expect(response.status).to eq(200) + expect(response.body).to_not have_tag(:script, with: { src: '/assets/application.js' }) + expect(response.body).to include(I18n.t('invite.not_found', base_url: Discourse.base_url)) + end + + it 'fails if invite expired' do invite.update(expires_at: 1.day.ago) get "/invites/#{invite.invite_key}" - expect(response.status).to eq(200) - - body = response.body - expect(body).to_not have_tag(:script, with: { src: '/assets/application.js' }) - expect(CGI.unescapeHTML(body)).to include(I18n.t('invite.expired', base_url: Discourse.base_url)) + expect(response.body).to_not have_tag(:script, with: { src: '/assets/application.js' }) + expect(response.body).to include(I18n.t('invite.expired', base_url: Discourse.base_url)) end - it "renders the accept invite page if invite exists" do - get "/invites/#{invite.invite_key}" - - expect(response.status).to eq(200) - - body = response.body - expect(body).to have_tag(:script, with: { src: '/assets/application.js' }) - expect(CGI.unescapeHTML(body)).to_not include(I18n.t('invite.not_found_template', site_name: SiteSetting.title, base_url: Discourse.base_url)) - end - - it "stores the invite key in the secure session if invite exists" do + it 'stores the invite key in the secure session if invite exists' do get "/invites/#{invite.invite_key}" expect(response.status).to eq(200) - invite_key = read_secure_session["invite-key"] + invite_key = read_secure_session['invite-key'] expect(invite_key).to eq(invite.invite_key) end - it "returns error if invite has already been redeemed" do + it 'returns error if invite has already been redeemed' do Fabricate(:invited_user, invite: invite, user: Fabricate(:user)) + get "/invites/#{invite.invite_key}" - expect(response.status).to eq(200) - - body = response.body - expect(body).to_not have_tag(:script, with: { src: '/assets/application.js' }) - expect(CGI.unescapeHTML(body)).to include(I18n.t('invite.not_found_template', site_name: SiteSetting.title, base_url: Discourse.base_url)) - end - end - - context '#destroy' do - it 'requires you to be logged in' do - delete "/invites.json", - params: { email: 'jake@adventuretime.ooo' } - expect(response.status).to eq(403) - end - - context 'while logged in' do - let!(:user) { sign_in(Fabricate(:user)) } - let!(:invite) { Fabricate(:invite, invited_by: user) } - fab!(:another_invite) { Fabricate(:invite, email: 'anotheremail@address.com') } - - it 'raises an error when the id is missing' do - delete "/invites.json" - expect(response.status).to eq(400) - end - - it "raises an error when the id cannot be found" do - delete "/invites.json", params: { id: 848 } - expect(response.status).to eq(400) - end - - it 'raises an error when the invite is not yours' do - delete "/invites.json", params: { id: another_invite.id } - expect(response.status).to eq(400) - end - - it "destroys the invite" do - delete "/invites.json", params: { id: invite.id } - - expect(response.status).to eq(200) - - invite.reload - expect(invite.trashed?).to be_truthy - end + expect(response.body).to_not have_tag(:script, with: { src: '/assets/application.js' }) + expect(response.body).to include(I18n.t('invite.not_found_template', site_name: SiteSetting.title, base_url: Discourse.base_url)) end end context '#create' do - it 'requires you to be logged in' do - post "/invites.json", params: { email: 'jake@adventuretime.ooo' } + it 'requires to be logged in' do + post '/invites.json', params: { email: 'test@example.com' } expect(response.status).to eq(403) end context 'while logged in' do - let(:email) { 'jake@adventuretime.ooo' } + before do + sign_in(user) + end - it "fails if you can't invite to the forum" do + it 'fails if you cannot invite to the forum' do sign_in(Fabricate(:user)) - post "/invites.json", params: { email: email } + + post '/invites.json', params: { email: 'test@example.com' } expect(response).to be_forbidden end - it "fails for normal user if invite email already exists" do - user = sign_in(trust_level_4) - invite = Invite.generate(user, email: "invite@example.com") - post "/invites.json", params: { email: invite.email } + it 'fails for normal user if invite email already exists' do + Fabricate(:invite, invited_by: user, email: 'test@example.com') + + post '/invites.json', params: { email: 'test@example.com' } expect(response.status).to eq(422) - expect(response.parsed_body["failed"]).to be_present - end - - it "allows admins to invite to groups" do - group = Fabricate(:group) - sign_in(admin) - post "/invites.json", params: { email: email, group_ids: [group.id] } - expect(response.status).to eq(200) - expect(Invite.find_by(email: email).invited_groups.count).to eq(1) - end - - it 'allows group owners to invite to groups' do - group = Fabricate(:group) - user = sign_in(Fabricate(:user)) - user.update!(trust_level: TrustLevel[2]) - group.add_owner(user) - - post "/invites.json", params: { email: email, group_ids: [group.id] } - - expect(response.status).to eq(200) - expect(Invite.find_by(email: email).invited_groups.count).to eq(1) - end - - it "does not allow admins to send multiple invites to same email" do - user = sign_in(admin) - invite = Invite.generate(user, email: "invite@example.com") - post "/invites.json", params: { email: invite.email } - expect(response.status).to eq(422) - end - - it "responds with error message in case of validation failure" do - sign_in(admin) - post "/invites.json", params: { email: "test@mailinator.com" } - expect(response.status).to eq(422) - expect(response.parsed_body["errors"]).to be_present + expect(response.parsed_body['failed']).to be_present end end - describe 'single use invite link' do - it 'requires you to be logged in' do - post "/invites.json", params: { - email: 'jake@adventuretime.ooo', skip_email: true - } + context 'invite to topic' do + fab!(:topic) { Fabricate(:topic) } + + it 'works' do + sign_in(user) + + post '/invites.json', params: { email: 'test@example.com', topic_id: topic.id } + expect(response.status).to eq(200) + expect(Jobs::InviteEmail.jobs.first['args'].first['invite_to_topic']).to be_falsey + end + + it 'fails when topic_id is invalid' do + sign_in(user) + + post '/invites.json', params: { email: 'test@example.com', topic_id: -9999 } + expect(response.status).to eq(400) + end + end + + context 'invite to group' do + fab!(:group) { Fabricate(:group) } + + it 'works for admins' do + sign_in(admin) + + post '/invites.json', params: { email: 'test@example.com', group_ids: [group.id] } + expect(response.status).to eq(200) + expect(Invite.find_by(email: 'test@example.com').invited_groups.count).to eq(1) + end + + it 'works for group owners' do + sign_in(user) + group.add_owner(user) + + post '/invites.json', params: { email: 'test@example.com', group_ids: [group.id] } + expect(response.status).to eq(200) + expect(Invite.find_by(email: 'test@example.com').invited_groups.count).to eq(1) + end + + it 'works with multiple groups' do + sign_in(admin) + group2 = Fabricate(:group) + + post '/invites.json', params: { email: 'test@example.com', group_names: "#{group.name},#{group2.name}" } + expect(response.status).to eq(200) + expect(Invite.find_by(email: 'test@example.com').invited_groups.count).to eq(2) + end + + it 'fails for group members' do + sign_in(user) + group.add(user) + + post '/invites.json', params: { email: 'test@example.com', group_ids: [group.id] } expect(response.status).to eq(403) end - context 'while logged in' do - let(:email) { 'jake@adventuretime.ooo' } + it 'fails for other users' do + sign_in(user) - it "fails if you can't invite to the forum" do - sign_in(Fabricate(:user)) - post "/invites.json", params: { email: email, skip_email: true } - expect(response.status).to eq(403) - end + post '/invites.json', params: { email: 'test@example.com', group_ids: [group.id] } + expect(response.status).to eq(403) + end - it "fails for normal user if invite email already exists" do - user = sign_in(trust_level_4) - invite = Invite.generate(user, email: "invite@example.com") + it 'fails to invite new user to a group-private topic' do + sign_in(user) + private_category = Fabricate(:private_category, group: group) + group_private_topic = Fabricate(:topic, category: private_category) - post "/invites.json", params: { email: invite.email, skip_email: true } - expect(response.status).to eq(422) - end - - it "fails when topic_id is invalid" do - sign_in(trust_level_4) - - post "/invites.json", params: { email: email, skip_email: true, topic_id: -9999 } - expect(response.status).to eq(400) - end - - it "verifies that inviter is authorized to invite new user to a group-private topic" do - group = Fabricate(:group) - private_category = Fabricate(:private_category, group: group) - group_private_topic = Fabricate(:topic, category: private_category) - sign_in(trust_level_4) - - post "/invites.json", params: { - email: email, skip_email: true, topic_id: group_private_topic.id - } - - expect(response.status).to eq(403) - end - - it "allows admins to invite to groups" do - group = Fabricate(:group) - sign_in(admin) - - post "/invites.json", params: { - email: email, skip_email: true, group_ids: [group.id] - } - - expect(response.status).to eq(200) - expect(Invite.find_by(email: email).invited_groups.count).to eq(1) - end - - it "allows multiple group invite" do - Fabricate(:group, name: "security") - Fabricate(:group, name: "support") - sign_in(admin) - - post "/invites.json", params: { - email: email, skip_email: true, group_names: "security,support" - } - - expect(response.status).to eq(200) - expect(Invite.find_by(email: email).invited_groups.count).to eq(2) - end + post '/invites.json', params: { email: 'test@example.com', topic_id: group_private_topic.id } + expect(response.status).to eq(403) end end - describe 'multiple use invite link' do - it 'requires you to be logged in' do - post "/invites.json", params: { - max_redemptions_allowed: 5 - } - expect(response).to be_forbidden + context 'email invite' do + it 'does not allow users to send multiple invites to same email' do + sign_in(user) + + post '/invites.json', params: { email: 'test@example.com' } + expect(response.status).to eq(200) + expect(Jobs::InviteEmail.jobs.size).to eq(1) + + post '/invites.json', params: { email: 'test@example.com' } + expect(response.status).to eq(422) end - context 'while logged in' do - it "allows staff to invite to groups" do - moderator = Fabricate(:moderator) - sign_in(moderator) - group = Fabricate(:group) - group.add_owner(moderator) + it 'accept skip_email parameter' do + sign_in(user) - post "/invites.json", params: { - max_redemptions_allowed: 5, - group_ids: [group.id] - } + post '/invites.json', params: { email: 'test@example.com', skip_email: true } + expect(response.status).to eq(200) + expect(Jobs::InviteEmail.jobs.size).to eq(0) + end - expect(response.status).to eq(200) - expect(Invite.last.invited_groups.count).to eq(1) - end + it 'fails in case of validation failure' do + sign_in(admin) - it "allows multiple group invite" do - Fabricate(:group, name: "security") - Fabricate(:group, name: "support") - sign_in(admin) + post '/invites.json', params: { email: 'test@mailinator.com' } + expect(response.status).to eq(422) + expect(response.parsed_body['errors']).to be_present + end + end - post "/invites.json", params: { - max_redemptions_allowed: 5, - group_names: "security,support" - } + context 'link invite' do + it 'works' do + sign_in(admin) - expect(response.status).to eq(200) - expect(Invite.last.invited_groups.count).to eq(2) - end + post '/invites.json' + expect(response.status).to eq(200) + expect(Invite.last.email).to eq(nil) + expect(Invite.last.invited_by).to eq(admin) + expect(Invite.last.max_redemptions_allowed).to eq(1) + end + + it 'fails if over invite_link_max_redemptions_limit' do + sign_in(admin) + + post '/invites.json', params: { max_redemptions_allowed: SiteSetting.invite_link_max_redemptions_limit - 1 } + expect(response.status).to eq(200) + + post '/invites.json', params: { max_redemptions_allowed: SiteSetting.invite_link_max_redemptions_limit + 1 } + expect(response.status).to eq(422) + end + + it 'fails if over invite_link_max_redemptions_limit_users' do + sign_in(user) + + post '/invites.json', params: { max_redemptions_allowed: SiteSetting.invite_link_max_redemptions_limit_users - 1 } + expect(response.status).to eq(200) + + post '/invites.json', params: { max_redemptions_allowed: SiteSetting.invite_link_max_redemptions_limit_users + 1 } + expect(response.status).to eq(422) end end end @@ -282,115 +234,176 @@ describe InvitesController do context '#update' do fab!(:invite) { Fabricate(:invite, invited_by: admin, email: 'test@example.com') } - before do - sign_in(admin) - RateLimiter.enable - end - - after do - RateLimiter.disable - end - - it 'updating email address resends invite email' do + it 'requires to be logged in' do put "/invites/#{invite.id}", params: { email: 'test2@example.com' } - - expect(response.status).to eq(200) - expect(Jobs::InviteEmail.jobs.size).to eq(1) + expect(response.status).to eq(400) end - it 'updating does not resend invite email' do - put "/invites/#{invite.id}", params: { custom_message: "new message" } + context 'while logged in' do + before do + sign_in(admin) + end - expect(response.status).to eq(200) - expect(invite.reload.custom_message).to eq("new message") - expect(Jobs::InviteEmail.jobs.size).to eq(0) + it 'resends invite email if updating email address' do + put "/invites/#{invite.id}", params: { email: 'test2@example.com' } + expect(response.status).to eq(200) + expect(Jobs::InviteEmail.jobs.size).to eq(1) + end + + it 'does not resend invite email if skip_email if updating email address' do + put "/invites/#{invite.id}", params: { email: 'test2@example.com', skip_email: true } + expect(response.status).to eq(200) + expect(Jobs::InviteEmail.jobs.size).to eq(0) + end + + it 'does not resend invite email when updating other fields' do + put "/invites/#{invite.id}", params: { custom_message: 'new message' } + expect(response.status).to eq(200) + expect(invite.reload.custom_message).to eq('new message') + expect(Jobs::InviteEmail.jobs.size).to eq(0) + end + + it 'can send invite email' do + sign_in(user) + RateLimiter.enable + RateLimiter.clear_all! + + invite = Fabricate(:invite, invited_by: user, email: 'test@example.com') + + expect { put "/invites/#{invite.id}", params: { send_email: true } } + .to change { RateLimiter.new(user, 'resend-invite-per-hour', 10, 1.hour).remaining }.by(-1) + expect(response.status).to eq(200) + expect(Jobs::InviteEmail.jobs.size).to eq(1) + ensure + RateLimiter.disable + end + end + end + + context '#destroy' do + it 'requires to be logged in' do + delete '/invites.json', params: { email: 'test@example.com' } + expect(response.status).to eq(403) end - it 'can send invite email' do - user = Fabricate(:user, trust_level: SiteSetting.min_trust_level_to_allow_invite) - invite = Fabricate(:invite, invited_by: user, email: 'test@example.com') + context 'while logged in' do + fab!(:invite) { Fabricate(:invite, invited_by: user) } - sign_in(user) - RateLimiter.enable + before { sign_in(user) } - expect { put "/invites/#{invite.id}", params: { send_email: true } } - .to change { RateLimiter.new(user, "resend-invite-per-hour", 10, 1.hour).remaining }.by(-1) + it 'raises an error when id is missing' do + delete '/invites.json' + expect(response.status).to eq(400) + end - expect(response.status).to eq(200) - expect(Jobs::InviteEmail.jobs.size).to eq(1) + it 'raises an error when invite does not exist' do + delete '/invites.json', params: { id: 848 } + expect(response.status).to eq(400) + end + + it 'raises an error when invite is not created by user' do + another_invite = Fabricate(:invite, email: 'test2@example.com') + + delete '/invites.json', params: { id: another_invite.id } + expect(response.status).to eq(400) + end + + it 'destroys the invite' do + delete '/invites.json', params: { id: invite.id } + expect(response.status).to eq(200) + expect(invite.reload.trashed?).to be_truthy + end end end context '#perform_accept_invitation' do - context 'with an invalid invite id' do - it "redirects to the root and doesn't change the session" do - put "/invites/show/doesntexist.json" + context 'with an invalid invite' do + it 'redirects to the root' do + put '/invites/show/doesntexist.json' expect(response.status).to eq(404) - expect(response.parsed_body["message"]).to eq(I18n.t('invite.not_found_json')) - expect(session[:current_user_id]).to be_blank - end - end - - context 'with an invalid invite record' do - let(:invite) { Fabricate(:invite) } - it "responds with error message" do - invite.update_attribute(:email, "John Doe <john.doe@example.com>") - put "/invites/show/#{invite.invite_key}.json" - expect(response.status).to eq(412) - expect(response.parsed_body["message"]).to eq(I18n.t('invite.error_message')) + expect(response.parsed_body['message']).to eq(I18n.t('invite.not_found_json')) expect(session[:current_user_id]).to be_blank end end context 'with a deleted invite' do - fab!(:topic) { Fabricate(:topic) } - let(:invite) { Invite.generate(topic.user, email: "iceking@adventuretime.ooo", topic: topic) } + fab!(:invite) { Fabricate(:invite) } before do - invite.destroy! + invite.trash! end - it "redirects to the root" do + it 'redirects to the root' do put "/invites/show/#{invite.invite_key}.json" - expect(response.status).to eq(404) - expect(response.parsed_body["message"]).to eq(I18n.t('invite.not_found_json')) + expect(response.parsed_body['message']).to eq(I18n.t('invite.not_found_json')) expect(session[:current_user_id]).to be_blank end end context 'with an expired invite' do - fab!(:invite_link) { Fabricate(:invite, email: nil, max_redemptions_allowed: 5, expires_at: 1.day.ago, emailed_status: Invite.emailed_status_types[:not_required]) } - - it "response is not successful" do - put "/invites/show/#{invite_link.invite_key}.json", params: { email: "foobar@example.com" } + fab!(:invite) { Fabricate(:invite, expires_at: 1.day.ago) } + it 'response is not successful' do + put "/invites/show/#{invite.invite_key}.json" expect(response.status).to eq(404) - expect(response.parsed_body["message"]).to eq(I18n.t('invite.not_found_json')) + expect(response.parsed_body['message']).to eq(I18n.t('invite.not_found_json')) expect(session[:current_user_id]).to be_blank end end - context 'with a valid invite id' do - fab!(:topic) { Fabricate(:topic) } - let(:invite) { Invite.generate(topic.user, email: "iceking@adventuretime.ooo", topic: topic) } + context 'with an email invite' do + let(:topic) { Fabricate(:topic) } + let(:invite) { Invite.generate(topic.user, email: 'iceking@adventuretime.ooo', topic: topic) } it 'redeems the invite' do put "/invites/show/#{invite.invite_key}.json" - invite.reload - expect(invite.redeemed?).to be_truthy + expect(invite.reload.redeemed?).to be_truthy end - it 'returns the right response when local login is disabled and no external auth is configured' do + it 'logs in the user' do + events = DiscourseEvent.track_events do + put "/invites/show/#{invite.invite_key}.json" + end + + expect(events.map { |event| event[:event_name] }).to include(:user_logged_in, :user_first_logged_in) + expect(response.status).to eq(200) + expect(session[:current_user_id]).to eq(invite.invited_users.first.user_id) + expect(invite.reload.redeemed?).to be_truthy + user = User.find(invite.invited_users.first.user_id) + expect(user.ip_address).to be_present + expect(user.registration_ip_address).to be_present + end + + it 'redirects to the first topic the user was invited to' do + put "/invites/show/#{invite.invite_key}.json" + expect(response.status).to eq(200) + expect(response.parsed_body['redirect_to']).to eq(topic.relative_url) + end + + it 'sets the timezone of the user in user_options' do + put "/invites/show/#{invite.invite_key}.json", params: { timezone: 'Australia/Melbourne' } + expect(response.status).to eq(200) + invite.reload + user = User.find(invite.invited_users.first.user_id) + expect(user.user_option.timezone).to eq('Australia/Melbourne') + end + + it 'does not log in the user if there are validation errors' do + put "/invites/show/#{invite.invite_key}.json", params: { password: 'password' } + expect(response.status).to eq(412) + expect(response.parsed_body['errors']['password']).to be_present + end + + it 'fails when local login is disabled and no external auth is configured' do SiteSetting.enable_local_logins = false put "/invites/show/#{invite.invite_key}.json" - expect(response.status).to eq(404) end - describe 'with authentication session' do - let(:authenticated_email) { "foobar@example.com" } + context 'with OmniAuth provider' do + fab!(:authenticated_email) { 'test@example.com' } before do OmniAuth.config.test_mode = true @@ -406,204 +419,146 @@ describe InvitesController do raw_info: OmniAuth::AuthHash.new( email_verified: true, email: authenticated_email, - family_name: "Last", - given_name: "First", - gender: "male", - name: "First Last", + family_name: 'Last', + given_name: 'First', + gender: 'male', + name: 'First Last', ) }, ) - Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:google_oauth2] + Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:google_oauth2] SiteSetting.enable_google_oauth2_logins = true - get "/auth/google_oauth2/callback.json" + get '/auth/google_oauth2/callback.json' expect(response.status).to eq(302) end after do - Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:google_oauth2] = nil + Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:google_oauth2] = nil OmniAuth.config.test_mode = false end it 'should associate the invited user with authenticator records' do - invite.update!(email: authenticated_email) SiteSetting.auth_overrides_name = true + invite.update!(email: authenticated_email) - expect do - put "/invites/show/#{invite.invite_key}.json", - params: { name: 'somename' } - - expect(response.status).to eq(200) - end.to change { User.with_email(authenticated_email).exists? }.to(true) + expect { put "/invites/show/#{invite.invite_key}.json", params: { name: 'somename' } } + .to change { User.with_email(authenticated_email).exists? }.to(true) + expect(response.status).to eq(200) user = User.find_by_email(authenticated_email) - expect(user.name).to eq('First Last') - - expect(user.user_associated_accounts.first.provider_name) - .to eq("google_oauth2") + expect(user.user_associated_accounts.first.provider_name).to eq('google_oauth2') end it 'returns the right response even if local logins has been disabled' do SiteSetting.enable_local_logins = false - invite.update!(email: authenticated_email) put "/invites/show/#{invite.invite_key}.json" - expect(response.status).to eq(200) end it 'returns the right response if authenticated email does not match invite email' do put "/invites/show/#{invite.invite_key}.json" - expect(response.status).to eq(412) end end - context 'when redeem returns a user' do - fab!(:user) { Fabricate(:coding_horror) } + context '.post_process_invite' do + it 'sends a welcome message if set' do + SiteSetting.send_welcome_message = true + user.send_welcome_message = true + put "/invites/show/#{invite.invite_key}.json" + expect(response.status).to eq(200) - context 'success' do - it 'logs in the user' do - events = DiscourseEvent.track_events do - put "/invites/show/#{invite.invite_key}.json" - end + expect(Jobs::SendSystemMessage.jobs.size).to eq(1) + end - expect(events.map { |event| event[:event_name] }).to include( - :user_logged_in, :user_first_logged_in - ) - invite.reload - expect(response.status).to eq(200) - expect(session[:current_user_id]).to eq(invite.invited_users.first.user_id) - expect(invite.redeemed?).to be_truthy - user = User.find(invite.invited_users.first.user_id) - expect(user.ip_address).to be_present - expect(user.registration_ip_address).to be_present - end + it 'refreshes automatic groups if staff' do + topic.user.grant_admin! + invite.update!(moderator: true) - it 'redirects to the first topic the user was invited to' do + put "/invites/show/#{invite.invite_key}.json" + expect(response.status).to eq(200) + + expect(invite.invited_users.first.user.groups.pluck(:name)).to contain_exactly('moderators', 'staff') + end + + context 'without password' do + it 'sends password reset email' do put "/invites/show/#{invite.invite_key}.json" expect(response.status).to eq(200) - expect(response.parsed_body["redirect_to"]).to eq(topic.relative_url) - end - context "if a timezone guess is provided" do - it "sets the timezone of the user in user_options" do - put "/invites/show/#{invite.invite_key}.json", params: { timezone: "Australia/Melbourne" } - expect(response.status).to eq(200) - invite.reload - user = User.find(invite.invited_users.first.user_id) - expect(user.user_option.timezone).to eq("Australia/Melbourne") - end + expect(Jobs::InvitePasswordInstructionsEmail.jobs.size).to eq(1) + expect(Jobs::CriticalUserEmail.jobs.size).to eq(0) end end - context 'failure' do - it "doesn't log in the user if there's a validation error" do - put "/invites/show/#{invite.invite_key}.json", params: { password: "password" } - expect(response.status).to eq(412) - expect(response.parsed_body["errors"]["password"]).to be_present - end - end + context 'with password' do + context 'user was invited via email' do + before { invite.update_column(:emailed_status, Invite.emailed_status_types[:pending]) } - context '.post_process_invite' do - it 'sends a welcome message if set' do - user.send_welcome_message = true - put "/invites/show/#{invite.invite_key}.json" - expect(response.status).to eq(200) + it 'does not send an activation email and activates the user' do + expect do + put "/invites/show/#{invite.invite_key}.json", params: { password: 'verystrongpassword' } + end.to change { UserAuthToken.count }.by(1) - expect(Jobs::SendSystemMessage.jobs.size).to eq(1) - end - - it 'refreshes automatic groups if staff' do - topic.user.grant_admin! - invite.update!(moderator: true) - - put "/invites/show/#{invite.invite_key}.json" - expect(response.status).to eq(200) - - expect(invite.invited_users.first.user.groups.pluck(:name)).to contain_exactly("moderators", "staff") - end - - context "without password" do - it "sends password reset email" do - put "/invites/show/#{invite.invite_key}.json" expect(response.status).to eq(200) - expect(Jobs::InvitePasswordInstructionsEmail.jobs.size).to eq(1) + expect(Jobs::InvitePasswordInstructionsEmail.jobs.size).to eq(0) expect(Jobs::CriticalUserEmail.jobs.size).to eq(0) + + invited_user = User.find_by_email(invite.email) + expect(invited_user.active).to eq(true) + expect(invited_user.email_confirmed?).to eq(true) end end - context "with password" do - context "user was invited via email" do - before { invite.update_column(:emailed_status, Invite.emailed_status_types[:pending]) } + context 'user was invited via link' do + before { invite.update_column(:emailed_status, Invite.emailed_status_types[:not_required]) } - it "doesn't send an activation email and activates the user" do - expect do - put "/invites/show/#{invite.invite_key}.json", params: { password: "verystrongpassword" } - end.to change { UserAuthToken.count }.by(1) + it 'sends an activation email and does not activate the user' do + expect do + put "/invites/show/#{invite.invite_key}.json", params: { password: 'verystrongpassword' } + end.not_to change { UserAuthToken.count } - expect(response.status).to eq(200) + expect(response.status).to eq(200) + expect(response.parsed_body['message']).to eq(I18n.t('invite.confirm_email')) - expect(Jobs::InvitePasswordInstructionsEmail.jobs.size).to eq(0) - expect(Jobs::CriticalUserEmail.jobs.size).to eq(0) + invited_user = User.find_by_email(invite.email) + expect(invited_user.active).to eq(false) + expect(invited_user.email_confirmed?).to eq(false) - invited_user = User.find_by_email(invite.email) - expect(invited_user.active).to eq(true) - expect(invited_user.email_confirmed?).to eq(true) - end - end + expect(Jobs::InvitePasswordInstructionsEmail.jobs.size).to eq(0) + expect(Jobs::CriticalUserEmail.jobs.size).to eq(1) - context "user was invited via link" do - before { invite.update_column(:emailed_status, Invite.emailed_status_types[:not_required]) } + tokens = EmailToken.where(user_id: invited_user.id, confirmed: false, expired: false).pluck(:token) + expect(tokens.size).to eq(1) - it "sends an activation email and doesn't activate the user" do - expect do - put "/invites/show/#{invite.invite_key}.json", params: { password: "verystrongpassword" } - end.not_to change { UserAuthToken.count } - - expect(response.status).to eq(200) - expect(response.parsed_body["message"]).to eq(I18n.t("invite.confirm_email")) - - invited_user = User.find_by_email(invite.email) - expect(invited_user.active).to eq(false) - expect(invited_user.email_confirmed?).to eq(false) - - expect(Jobs::InvitePasswordInstructionsEmail.jobs.size).to eq(0) - expect(Jobs::CriticalUserEmail.jobs.size).to eq(1) - - tokens = EmailToken.where(user_id: invited_user.id, confirmed: false, expired: false).pluck(:token) - expect(tokens.size).to eq(1) - - job_args = Jobs::CriticalUserEmail.jobs.first["args"].first - expect(job_args["type"]).to eq("signup") - expect(job_args["user_id"]).to eq(invited_user.id) - expect(job_args["email_token"]).to eq(tokens.first) - end + job_args = Jobs::CriticalUserEmail.jobs.first['args'].first + expect(job_args['type']).to eq('signup') + expect(job_args['user_id']).to eq(invited_user.id) + expect(job_args['email_token']).to eq(tokens.first) end end end end end - context 'with multiple use invite link' do - fab!(:invite_link) { Fabricate(:invite, email: nil, max_redemptions_allowed: 5, expires_at: 1.month.from_now, emailed_status: Invite.emailed_status_types[:not_required]) } + context 'with an invite link' do + fab!(:invite) { Fabricate(:invite, email: nil, emailed_status: Invite.emailed_status_types[:not_required]) } - it "sends an activation email and doesn't activate the user" do - expect do - put "/invites/show/#{invite_link.invite_key}.json", params: { email: "foobar@example.com", password: "verystrongpassword" } - end.not_to change { UserAuthToken.count } + it 'sends an activation email and does not activate the user' do + expect { put "/invites/show/#{invite.invite_key}.json", params: { email: 'test@example.com', password: 'verystrongpassword' } } + .not_to change { UserAuthToken.count } expect(response.status).to eq(200) - expect(response.parsed_body["message"]).to eq(I18n.t("invite.confirm_email")) + expect(response.parsed_body['message']).to eq(I18n.t('invite.confirm_email')) + expect(invite.reload.redemption_count).to eq(1) - invite_link.reload - expect(invite_link.redemption_count).to eq(1) - - invited_user = User.find_by_email("foobar@example.com") + invited_user = User.find_by_email('test@example.com') expect(invited_user.active).to eq(false) expect(invited_user.email_confirmed?).to eq(false) @@ -613,49 +568,44 @@ describe InvitesController do tokens = EmailToken.where(user_id: invited_user.id, confirmed: false, expired: false).pluck(:token) expect(tokens.size).to eq(1) - job_args = Jobs::CriticalUserEmail.jobs.first["args"].first - expect(job_args["type"]).to eq("signup") - expect(job_args["user_id"]).to eq(invited_user.id) - expect(job_args["email_token"]).to eq(tokens.first) + job_args = Jobs::CriticalUserEmail.jobs.first['args'].first + expect(job_args['type']).to eq('signup') + expect(job_args['user_id']).to eq(invited_user.id) + expect(job_args['email_token']).to eq(tokens.first) end end context 'new registrations are disabled' do fab!(:topic) { Fabricate(:topic) } - - let(:invite) { Invite.generate(topic.user, email: "iceking@adventuretime.ooo", topic: topic) } + fab!(:invite) { Invite.generate(topic.user, email: 'test@example.com', topic: topic) } before { SiteSetting.allow_new_registrations = false } - it "doesn't redeem the invite" do + it 'does not redeem the invite' do put "/invites/show/#{invite.invite_key}.json" expect(response.status).to eq(200) - invite.reload - expect(invite.invited_users).to be_blank + expect(invite.reload.invited_users).to be_blank expect(invite.redeemed?).to be_falsey - expect(response.body).to include(I18n.t("login.new_registrations_disabled")) + expect(response.body).to include(I18n.t('login.new_registrations_disabled')) end end context 'user is already logged in' do - fab!(:topic) { Fabricate(:topic) } + fab!(:invite) { Fabricate(:invite, email: 'test@example.com') } + fab!(:user) { sign_in(Fabricate(:user)) } - let(:invite) { Invite.generate(topic.user, email: "iceking@adventuretime.ooo", topic: topic) } - - let!(:user) { sign_in(Fabricate(:user)) } - - it "doesn't redeem the invite" do + it 'does not redeem the invite' do put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key } expect(response.status).to eq(200) invite.reload expect(invite.invited_users).to be_blank expect(invite.redeemed?).to be_falsey - expect(response.body).to include(I18n.t("login.already_logged_in", current_user: user.username)) + expect(response.body).to include(I18n.t('login.already_logged_in', current_user: user.username)) end end end - context "#destroy_all" do + context '#destroy_all_expired' do it 'removes all expired invites sent by a user' do SiteSetting.invite_expiry_days = 1 @@ -666,7 +616,7 @@ describe InvitesController do expired_invite.update!(expires_at: 2.days.ago) sign_in(user) - post "/invites/destroy-all-expired" + post '/invites/destroy-all-expired' expect(response.status).to eq(200) expect(invite_1.reload.deleted_at).to eq(nil) @@ -676,33 +626,33 @@ describe InvitesController do end context '#resend_invite' do - it 'requires you to be logged in' do - post "/invites/reinvite.json", params: { email: 'first_name@example.com' } + it 'requires to be logged in' do + post '/invites/reinvite.json', params: { email: 'first_name@example.com' } expect(response.status).to eq(403) end context 'while logged in' do - let!(:user) { sign_in(Fabricate(:user)) } - let!(:invite) { Fabricate(:invite, invited_by: user) } + fab!(:user) { sign_in(Fabricate(:user)) } + fab!(:invite) { Fabricate(:invite, invited_by: user) } fab!(:another_invite) { Fabricate(:invite, email: 'last_name@example.com') } it 'raises an error when the email is missing' do - post "/invites/reinvite.json" + post '/invites/reinvite.json' expect(response.status).to eq(400) end - it "raises an error when the email cannot be found" do - post "/invites/reinvite.json", params: { email: 'first_name@example.com' } + it 'raises an error when the email cannot be found' do + post '/invites/reinvite.json', params: { email: 'first_name@example.com' } expect(response.status).to eq(400) end it 'raises an error when the invite is not yours' do - post "/invites/reinvite.json", params: { email: another_invite.email } + post '/invites/reinvite.json', params: { email: another_invite.email } expect(response.status).to eq(400) end - it "resends the invite" do - post "/invites/reinvite.json", params: { email: invite.email } + it 'resends the invite' do + post '/invites/reinvite.json', params: { email: invite.email } expect(response.status).to eq(200) expect(Jobs::InviteEmail.jobs.size).to eq(1) end @@ -722,7 +672,7 @@ describe InvitesController do redeemed_invite.update!(expires_at: 5.days.ago) sign_in(user) - post "/invites/reinvite-all" + post '/invites/reinvite-all' expect(response.status).to eq(200) expect(new_invite.reload.expires_at.to_date).to eq(30.days.from_now.to_date) @@ -732,41 +682,36 @@ describe InvitesController do end context '#upload_csv' do - it 'requires you to be logged in' do - post "/invites/upload_csv.json" + it 'requires to be logged in' do + post '/invites/upload_csv.json' expect(response.status).to eq(403) end context 'while logged in' do let(:csv_file) { File.new("#{Rails.root}/spec/fixtures/csv/discourse.csv") } + let(:file) { Rack::Test::UploadedFile.new(File.open(csv_file)) } - let(:file) do - Rack::Test::UploadedFile.new(File.open(csv_file)) - end - - let(:filename) { 'discourse.csv' } - - it "fails if you can't bulk invite to the forum" do + it 'fails if you cannot bulk invite to the forum' do sign_in(Fabricate(:user)) - post "/invites/upload_csv.json", params: { file: file, name: filename } + post '/invites/upload_csv.json', params: { file: file, name: 'discourse.csv' } expect(response.status).to eq(403) end - it "allows admin to bulk invite" do + it 'allows admin to bulk invite' do sign_in(admin) - post "/invites/upload_csv.json", params: { file: file, name: filename } + post '/invites/upload_csv.json', params: { file: file, name: 'discourse.csv' } expect(response.status).to eq(200) expect(Jobs::BulkInvite.jobs.size).to eq(1) end - it "sends limited invites at a time" do + it 'sends limited invites at a time' do SiteSetting.max_bulk_invites = 3 sign_in(admin) - post "/invites/upload_csv.json", params: { file: file, name: filename } + post '/invites/upload_csv.json', params: { file: file, name: 'discourse.csv' } expect(response.status).to eq(422) expect(Jobs::BulkInvite.jobs.size).to eq(1) - expect(response.parsed_body["errors"][0]).to eq(I18n.t("bulk_invite.max_rows", max_bulk_invites: SiteSetting.max_bulk_invites)) + expect(response.parsed_body['errors'][0]).to eq(I18n.t('bulk_invite.max_rows', max_bulk_invites: SiteSetting.max_bulk_invites)) end end end diff --git a/spec/requests/topics_controller_spec.rb b/spec/requests/topics_controller_spec.rb index 3b02ccd5eec..ae921e092fc 100644 --- a/spec/requests/topics_controller_spec.rb +++ b/spec/requests/topics_controller_spec.rb @@ -3377,6 +3377,7 @@ RSpec.describe TopicsController do end.to change { Invite.where(invited_by_id: user.id).count }.by(1) expect(response.status).to eq(200) + expect(Jobs::InviteEmail.jobs.first['args'].first['invite_to_topic']).to be_truthy end end diff --git a/test/javascripts/integration/components/invite-panel-test.js b/test/javascripts/integration/components/invite-panel-test.js deleted file mode 100644 index bdce109aa15..00000000000 --- a/test/javascripts/integration/components/invite-panel-test.js +++ /dev/null @@ -1,24 +0,0 @@ -import EmberObject, { set } from "@ember/object"; -import componentTest from "helpers/component-test"; -import { moduleForComponent } from "ember-qunit"; -import { queryAll } from "discourse/tests/helpers/qunit-helpers"; - -moduleForComponent("invite-panel", { integration: true }); - -componentTest("can_invite_via_email", { - template: "{{invite-panel panel=panel}}", - - beforeEach() { - set(this.currentUser, "details", { can_invite_via_email: true }); - const inviteModel = JSON.parse(JSON.stringify(this.currentUser)); - this.set("panel", { - id: "invite", - model: { inviteModel: EmberObject.create(inviteModel) }, - }); - }, - - async test(assert) { - await fillIn(".invite-user-input", "eviltrout@example.com"); - assert.ok(queryAll(".send-invite:disabled").length === 0); - }, -});