mirror of
https://ghfast.top/https://github.com/discourse/discourse-shared-edits.git
synced 2026-07-15 11:17:01 +08:00
Some checks failed
Discourse Plugin / ci (push) Has been cancelled
Allow sites to choose which groups can enable or disable shared edits instead of hardcoding staff and TL4 users. Expose the permission on the current user serializer so the admin menu can hide toggle actions for unauthorized users. Add rate limits to privileged reset and recover endpoints, and update specs, acceptance tests, docs, and settings copy for the new permission model. * Update config/settings.yml Co-authored-by: Martin Brennan <martin@discourse.org> * fix spec --------- Co-authored-by: Martin Brennan <martin@discourse.org>
1096 lines
35 KiB
Ruby
1096 lines
35 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
RSpec.describe DiscourseSharedEdits::RevisionController do
|
|
fab!(:user)
|
|
fab!(:tl4_user) { Fabricate(:user, trust_level: TrustLevel[4], refresh_auto_groups: true) }
|
|
fab!(:post1) { Fabricate(:post, user: user, raw: "Hello World, testing shared edits") }
|
|
fab!(:admin)
|
|
fab!(:group)
|
|
fab!(:toggle_group, :group)
|
|
fab!(:private_category) { Fabricate(:private_category, group: group) }
|
|
fab!(:private_post) { Fabricate(:post, topic: Fabricate(:topic, category: private_category)) }
|
|
|
|
describe "#enable" do
|
|
context "when admin" do
|
|
before { sign_in admin }
|
|
|
|
it "returns 404 when plugin is disabled" do
|
|
SiteSetting.shared_edits_enabled = false
|
|
put "/shared_edits/p/#{post1.id}/enable"
|
|
expect(response.status).to eq(404)
|
|
end
|
|
|
|
it "enables shared edits on a post" do
|
|
put "/shared_edits/p/#{post1.id}/enable"
|
|
expect(response.status).to eq(200)
|
|
|
|
post1.reload
|
|
expect(post1.custom_fields[DiscourseSharedEdits::SHARED_EDITS_ENABLED]).to eq(true)
|
|
end
|
|
end
|
|
|
|
context "when regular user" do
|
|
before { sign_in user }
|
|
|
|
it "returns 403" do
|
|
put "/shared_edits/p/#{post1.id}/enable"
|
|
expect(response.status).to eq(403)
|
|
end
|
|
end
|
|
|
|
context "when user belongs to a configured toggle group" do
|
|
before do
|
|
SiteSetting.shared_edits_toggle_allowed_groups = toggle_group.id.to_s
|
|
toggle_group.add(user)
|
|
sign_in user
|
|
end
|
|
|
|
it "enables shared edits on a post" do
|
|
put "/shared_edits/p/#{post1.id}/enable"
|
|
expect(response.status).to eq(200)
|
|
|
|
post1.reload
|
|
expect(post1.custom_fields[DiscourseSharedEdits::SHARED_EDITS_ENABLED]).to eq(true)
|
|
end
|
|
end
|
|
|
|
context "when anonymous" do
|
|
it "returns 403" do
|
|
put "/shared_edits/p/#{post1.id}/enable"
|
|
expect(response.status).to eq(403)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "restricted category authorization" do
|
|
before { sign_in tl4_user }
|
|
|
|
it "returns 403 for enable on a post in a restricted category" do
|
|
put "/shared_edits/p/#{private_post.id}/enable"
|
|
expect(response.status).to eq(403)
|
|
end
|
|
|
|
it "returns 403 for disable on a post in a restricted category" do
|
|
put "/shared_edits/p/#{private_post.id}/disable"
|
|
expect(response.status).to eq(403)
|
|
end
|
|
|
|
it "returns 403 for health on a post in a restricted category" do
|
|
get "/shared_edits/p/#{private_post.id}/health"
|
|
expect(response.status).to eq(403)
|
|
end
|
|
|
|
it "returns 403 for recover on a post in a restricted category" do
|
|
post "/shared_edits/p/#{private_post.id}/recover"
|
|
expect(response.status).to eq(403)
|
|
end
|
|
|
|
it "returns 403 for reset on a post in a restricted category" do
|
|
post "/shared_edits/p/#{private_post.id}/reset"
|
|
expect(response.status).to eq(403)
|
|
end
|
|
end
|
|
|
|
describe "#disable" do
|
|
context "when admin" do
|
|
before { sign_in admin }
|
|
|
|
it "disables shared edits on a post" do
|
|
SharedEditRevision.toggle_shared_edits!(post1.id, true)
|
|
|
|
put "/shared_edits/p/#{post1.id}/disable"
|
|
expect(response.status).to eq(200)
|
|
|
|
post1.reload
|
|
expect(post1.custom_fields[DiscourseSharedEdits::SHARED_EDITS_ENABLED]).to be_nil
|
|
end
|
|
|
|
it "does not disable shared edits when pending changes cannot be committed" do
|
|
SharedEditRevision.toggle_shared_edits!(post1.id, true)
|
|
state = SharedEditRevision.where(post_id: post1.id).order("version desc").first.raw
|
|
update = DiscourseSharedEdits::Yjs.update_from_state(state, "pending change")
|
|
|
|
SharedEditRevision.revise!(
|
|
post_id: post1.id,
|
|
user_id: user.id,
|
|
client_id: "test",
|
|
update: update,
|
|
)
|
|
|
|
revision_count = SharedEditRevision.where(post_id: post1.id).count
|
|
post1.topic.update!(closed: true)
|
|
|
|
put "/shared_edits/p/#{post1.id}/disable"
|
|
|
|
expect(response.status).to eq(422)
|
|
expect(response.parsed_body["error"]).to eq(
|
|
DiscourseSharedEdits::Protocol::Errors::DISABLE_FAILED,
|
|
)
|
|
|
|
post1.reload
|
|
expect(post1.custom_fields[DiscourseSharedEdits::SHARED_EDITS_ENABLED]).to eq(true)
|
|
expect(SharedEditRevision.where(post_id: post1.id).count).to eq(revision_count)
|
|
end
|
|
end
|
|
|
|
context "when regular user" do
|
|
before { sign_in user }
|
|
|
|
it "returns 403" do
|
|
put "/shared_edits/p/#{post1.id}/disable"
|
|
expect(response.status).to eq(403)
|
|
end
|
|
end
|
|
|
|
context "when user belongs to a configured toggle group" do
|
|
before do
|
|
SiteSetting.shared_edits_toggle_allowed_groups = toggle_group.id.to_s
|
|
toggle_group.add(user)
|
|
sign_in user
|
|
end
|
|
|
|
it "disables shared edits on a post" do
|
|
SharedEditRevision.toggle_shared_edits!(post1.id, true)
|
|
|
|
put "/shared_edits/p/#{post1.id}/disable"
|
|
expect(response.status).to eq(200)
|
|
|
|
post1.reload
|
|
expect(post1.custom_fields[DiscourseSharedEdits::SHARED_EDITS_ENABLED]).to be_nil
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "#latest" do
|
|
before do
|
|
sign_in admin
|
|
SharedEditRevision.toggle_shared_edits!(post1.id, true)
|
|
end
|
|
|
|
def latest_state_for(post)
|
|
SharedEditRevision.where(post_id: post.id).order("version desc").limit(1).pluck(:raw).first
|
|
end
|
|
|
|
it "returns the latest version" do
|
|
new_text = "1234" + post1.raw[4..]
|
|
latest_state = latest_state_for(post1)
|
|
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, new_text),
|
|
}
|
|
|
|
get "/shared_edits/p/#{post1.id}"
|
|
expect(response.status).to eq(200)
|
|
|
|
raw = response.parsed_body["raw"]
|
|
version = response.parsed_body["version"]
|
|
state = response.parsed_body["state"]
|
|
|
|
expect(raw[0..3]).to eq("1234")
|
|
expect(version).to eq(2)
|
|
expect(state).to be_present
|
|
end
|
|
|
|
it "returns 404 when no revisions exist" do
|
|
SharedEditRevision.where(post_id: post1.id).delete_all
|
|
|
|
get "/shared_edits/p/#{post1.id}"
|
|
expect(response.status).to eq(404)
|
|
end
|
|
|
|
it "returns 404 for non-existent post" do
|
|
get "/shared_edits/p/999999"
|
|
expect(response.status).to eq(404)
|
|
end
|
|
|
|
it "includes message_bus_last_id in response" do
|
|
get "/shared_edits/p/#{post1.id}"
|
|
|
|
expect(response.status).to eq(200)
|
|
expect(response.parsed_body).to have_key("message_bus_last_id")
|
|
expect(response.parsed_body["message_bus_last_id"]).to be_a(Integer)
|
|
end
|
|
|
|
it "returns correct message_bus_last_id after revisions" do
|
|
get "/shared_edits/p/#{post1.id}"
|
|
initial_last_id = response.parsed_body["message_bus_last_id"]
|
|
|
|
new_text = "Updated content here"
|
|
latest_state = latest_state_for(post1)
|
|
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, new_text),
|
|
}
|
|
|
|
get "/shared_edits/p/#{post1.id}"
|
|
new_last_id = response.parsed_body["message_bus_last_id"]
|
|
|
|
expect(new_last_id).to be > initial_last_id
|
|
end
|
|
|
|
it "allows client to subscribe without missing messages using message_bus_last_id" do
|
|
get "/shared_edits/p/#{post1.id}"
|
|
last_id_at_fetch = response.parsed_body["message_bus_last_id"]
|
|
|
|
new_text = "Edit made after fetch but before subscribe"
|
|
latest_state = latest_state_for(post1)
|
|
|
|
messages =
|
|
MessageBus.track_publish("/shared_edits/#{post1.id}") do
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "other_client",
|
|
update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, new_text),
|
|
}
|
|
end
|
|
|
|
expect(messages.length).to eq(1)
|
|
|
|
backlog = MessageBus.backlog("/shared_edits/#{post1.id}", last_id_at_fetch)
|
|
|
|
expect(backlog.length).to eq(1)
|
|
expect(backlog.first.data["version"]).to eq(2)
|
|
end
|
|
end
|
|
|
|
describe "#commit" do
|
|
before { sign_in admin }
|
|
|
|
it "commits pending changes to the post" do
|
|
SharedEditRevision.toggle_shared_edits!(post1.id, true)
|
|
new_text = "committed content " + post1.raw
|
|
state = SharedEditRevision.where(post_id: post1.id).order("version desc").first.raw
|
|
update = DiscourseSharedEdits::Yjs.update_from_state(state, new_text)
|
|
|
|
SharedEditRevision.revise!(
|
|
post_id: post1.id,
|
|
user_id: user.id,
|
|
client_id: "test",
|
|
update: update,
|
|
)
|
|
|
|
put "/shared_edits/p/#{post1.id}/commit"
|
|
expect(response.status).to eq(200)
|
|
|
|
post1.reload
|
|
expect(post1.raw).to eq(new_text)
|
|
end
|
|
|
|
it "returns 404 for non-existent post" do
|
|
put "/shared_edits/p/999999/commit"
|
|
expect(response.status).to eq(404)
|
|
end
|
|
|
|
it "returns 404 when shared edits are not enabled on the post" do
|
|
put "/shared_edits/p/#{post1.id}/commit"
|
|
expect(response.status).to eq(404)
|
|
end
|
|
|
|
it "returns an error when commit cannot be applied to a closed topic" do
|
|
SharedEditRevision.toggle_shared_edits!(post1.id, true)
|
|
original_raw = post1.raw
|
|
new_text = "pending shared edit on closed topic"
|
|
state = SharedEditRevision.where(post_id: post1.id).order("version desc").first.raw
|
|
update = DiscourseSharedEdits::Yjs.update_from_state(state, new_text)
|
|
|
|
SharedEditRevision.revise!(
|
|
post_id: post1.id,
|
|
user_id: user.id,
|
|
client_id: "test",
|
|
update: update,
|
|
)
|
|
|
|
post1.topic.update!(closed: true)
|
|
|
|
put "/shared_edits/p/#{post1.id}/commit"
|
|
|
|
expect(response.status).to eq(422)
|
|
expect(response.parsed_body["error"]).to eq(
|
|
DiscourseSharedEdits::Protocol::Errors::COMMIT_FAILED,
|
|
)
|
|
post1.reload
|
|
expect(post1.raw).to eq(original_raw)
|
|
end
|
|
end
|
|
|
|
describe "#revise" do
|
|
before do
|
|
sign_in admin
|
|
SharedEditRevision.toggle_shared_edits!(post1.id, true)
|
|
end
|
|
|
|
def latest_state_for(post)
|
|
SharedEditRevision.where(post_id: post.id).order("version desc").limit(1).pluck(:raw).first
|
|
end
|
|
|
|
it "can submit edits on a post" do
|
|
new_text = "1234" + post1.raw[4..]
|
|
latest_state = latest_state_for(post1)
|
|
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, new_text),
|
|
}
|
|
expect(response.status).to eq(200)
|
|
|
|
SharedEditRevision.commit!(post1.id)
|
|
|
|
post1.reload
|
|
expect(post1.raw[0..3]).to eq("1234")
|
|
end
|
|
|
|
it "returns state_hash for the revision created by each update" do
|
|
new_text = "1234" + post1.raw[4..]
|
|
latest_state = latest_state_for(post1)
|
|
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, new_text),
|
|
}
|
|
expect(response.status).to eq(200)
|
|
state_hash = response.parsed_body["state_hash"]
|
|
latest_revision = SharedEditRevision.where(post_id: post1.id).order("version desc").first
|
|
expected_hash = DiscourseSharedEdits::Yjs.compute_state_hash(latest_revision.raw)
|
|
expect(state_hash).to eq(expected_hash)
|
|
|
|
# Commit to compute state_hash
|
|
SharedEditRevision.commit!(post1.id)
|
|
|
|
latest_state = latest_state_for(post1)
|
|
new_text = "5678" + post1.raw[4..]
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, new_text),
|
|
}
|
|
expect(response.status).to eq(200)
|
|
state_hash = response.parsed_body["state_hash"]
|
|
latest_revision = SharedEditRevision.where(post_id: post1.id).order("version desc").first
|
|
expected_hash = DiscourseSharedEdits::Yjs.compute_state_hash(latest_revision.raw)
|
|
expect(state_hash).to eq(expected_hash)
|
|
end
|
|
|
|
it "rejects blanking a post without explicit allow flag" do
|
|
latest_state = latest_state_for(post1)
|
|
blank_update = DiscourseSharedEdits::Yjs.update_from_state(latest_state, "")
|
|
|
|
put "/shared_edits/p/#{post1.id}", params: { client_id: "abc", update: blank_update }
|
|
|
|
expect(response.status).to eq(422)
|
|
expect(response.parsed_body["error"]).to eq(
|
|
DiscourseSharedEdits::Protocol::Errors::BLANK_STATE_REJECTED,
|
|
)
|
|
end
|
|
|
|
it "allows blanking a post when allow flag is provided" do
|
|
latest_state = latest_state_for(post1)
|
|
blank_update = DiscourseSharedEdits::Yjs.update_from_state(latest_state, "")
|
|
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
update: blank_update,
|
|
allow_blank_state: true,
|
|
}
|
|
|
|
expect(response.status).to eq(200)
|
|
expect(response.parsed_body["version"]).to eq(2)
|
|
end
|
|
|
|
it "ignores allow_blank_state from non-privileged users" do
|
|
post_author = Fabricate(:user, refresh_auto_groups: true)
|
|
author_post = Fabricate(:post, user: post_author, raw: "Hello World, testing shared edits")
|
|
SharedEditRevision.toggle_shared_edits!(author_post.id, true)
|
|
sign_in(post_author)
|
|
|
|
latest_state =
|
|
SharedEditRevision
|
|
.where(post_id: author_post.id)
|
|
.order("version desc")
|
|
.limit(1)
|
|
.pluck(:raw)
|
|
.first
|
|
blank_update = DiscourseSharedEdits::Yjs.update_from_state(latest_state, "")
|
|
|
|
put "/shared_edits/p/#{author_post.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
update: blank_update,
|
|
allow_blank_state: true,
|
|
}
|
|
|
|
expect(response.status).to eq(422)
|
|
expect(response.parsed_body["error"]).to eq(
|
|
DiscourseSharedEdits::Protocol::Errors::BLANK_STATE_REJECTED,
|
|
)
|
|
end
|
|
|
|
it "requires client_id parameter" do
|
|
put "/shared_edits/p/#{post1.id}", params: { update: "test" }
|
|
expect(response.status).to eq(400)
|
|
end
|
|
|
|
it "rejects oversized client_id values" do
|
|
latest_state = latest_state_for(post1)
|
|
oversized_client_id = "a" * 256
|
|
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: oversized_client_id,
|
|
update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, "Updated content"),
|
|
}
|
|
|
|
expect(response.status).to eq(400)
|
|
expect(response.parsed_body["error"]).to eq(
|
|
DiscourseSharedEdits::Protocol::Errors::INVALID_UPDATE,
|
|
)
|
|
end
|
|
|
|
it "rejects oversized client_id for awareness-only updates" do
|
|
oversized_client_id = "a" * 256
|
|
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: oversized_client_id,
|
|
awareness: Base64.strict_encode64("awareness_data"),
|
|
}
|
|
|
|
expect(response.status).to eq(400)
|
|
expect(response.parsed_body["error"]).to eq(
|
|
DiscourseSharedEdits::Protocol::Errors::INVALID_UPDATE,
|
|
)
|
|
end
|
|
|
|
it "requires update or awareness parameter" do
|
|
put "/shared_edits/p/#{post1.id}", params: { client_id: "abc" }
|
|
expect(response.status).to eq(400)
|
|
end
|
|
|
|
it "accepts awareness-only updates without document update" do
|
|
messages =
|
|
MessageBus.track_publish("/shared_edits/#{post1.id}") do
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
awareness: Base64.strict_encode64("awareness_data"),
|
|
}
|
|
end
|
|
|
|
expect(response.status).to eq(200)
|
|
expect(messages.length).to eq(1)
|
|
expect(messages.first.data[:awareness]).to be_present
|
|
expect(messages.first.data[:client_id]).to eq("abc")
|
|
end
|
|
|
|
it "does not create revision for awareness-only updates" do
|
|
initial_count = SharedEditRevision.where(post_id: post1.id).count
|
|
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
awareness: Base64.strict_encode64("awareness_data"),
|
|
}
|
|
|
|
expect(response.status).to eq(200)
|
|
expect(SharedEditRevision.where(post_id: post1.id).count).to eq(initial_count)
|
|
end
|
|
|
|
it "rejects invalid awareness payloads" do
|
|
put "/shared_edits/p/#{post1.id}", params: { client_id: "abc", awareness: "not-base64" }
|
|
|
|
expect(response.status).to eq(400)
|
|
end
|
|
|
|
it "schedules a deferred commit" do
|
|
Discourse.redis.del SharedEditRevision.will_commit_key(post1.id)
|
|
|
|
new_text = "1234" + post1.raw[4..]
|
|
latest_state = latest_state_for(post1)
|
|
|
|
Sidekiq::Testing.inline! do
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, new_text),
|
|
}
|
|
|
|
get "/shared_edits/p/#{post1.id}"
|
|
expect(response.status).to eq(200)
|
|
|
|
raw = response.parsed_body["raw"]
|
|
version = response.parsed_body["version"]
|
|
|
|
expect(raw[0..3]).to eq("1234")
|
|
expect(version).to eq(2)
|
|
end
|
|
end
|
|
|
|
it "accepts multiple updates without client-side version tracking" do
|
|
first_text = "abcd" + post1.raw[4..]
|
|
second_text = "wxyz" + post1.raw[4..]
|
|
latest_state = latest_state_for(post1)
|
|
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, first_text),
|
|
}
|
|
|
|
latest_state = latest_state_for(post1)
|
|
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "123",
|
|
update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, second_text),
|
|
}
|
|
|
|
expect(response.status).to eq(200)
|
|
|
|
SharedEditRevision.commit!(post1.id)
|
|
|
|
post1.reload
|
|
expect(post1.raw[0..3]).to eq("wxyz")
|
|
end
|
|
|
|
it "handles corrupted state by requesting recovery text from client" do
|
|
# Corrupt the state
|
|
revision = SharedEditRevision.where(post_id: post1.id).first
|
|
revision.update_column(:raw, Base64.strict_encode64("corrupted data"))
|
|
|
|
# Use a valid base64 update that will pass input validation but fail
|
|
# when Yjs tries to apply it to the corrupted state
|
|
valid_update = Base64.strict_encode64("some binary data")
|
|
put "/shared_edits/p/#{post1.id}", params: { client_id: "abc", update: valid_update }
|
|
|
|
# Server now asks for recovery text instead of auto-recovering
|
|
expect(response.status).to eq(409)
|
|
expect(response.parsed_body["error"]).to eq(
|
|
DiscourseSharedEdits::Protocol::Errors::NEEDS_RECOVERY_TEXT,
|
|
)
|
|
end
|
|
|
|
describe "state vector validation" do
|
|
it "accepts updates when client state vector matches server" do
|
|
latest_state = latest_state_for(post1)
|
|
client_sv = DiscourseSharedEdits::Yjs.get_state_vector(latest_state)
|
|
client_sv_b64 = Base64.strict_encode64(client_sv.pack("C*"))
|
|
new_text = "Updated content"
|
|
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, new_text),
|
|
state_vector: client_sv_b64,
|
|
}
|
|
|
|
expect(response.status).to eq(200)
|
|
expect(response.parsed_body["version"]).to eq(2)
|
|
end
|
|
|
|
it "returns 409 with missing_update when client is behind server" do
|
|
latest_state = latest_state_for(post1)
|
|
client_sv = DiscourseSharedEdits::Yjs.get_state_vector(latest_state)
|
|
client_sv_b64 = Base64.strict_encode64(client_sv.pack("C*"))
|
|
|
|
update = DiscourseSharedEdits::Yjs.update_from_state(latest_state, "Server edit")
|
|
SharedEditRevision.revise!(
|
|
post_id: post1.id,
|
|
user_id: admin.id,
|
|
client_id: "other_client",
|
|
update: update,
|
|
)
|
|
|
|
new_text = "Client edit based on stale state"
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, new_text),
|
|
state_vector: client_sv_b64,
|
|
}
|
|
|
|
expect(response.status).to eq(409)
|
|
expect(response.parsed_body["error"]).to eq(
|
|
DiscourseSharedEdits::Protocol::Errors::STATE_DIVERGED,
|
|
)
|
|
expect(response.parsed_body["missing_update"]).to be_present
|
|
|
|
applied =
|
|
DiscourseSharedEdits::Yjs.apply_update(
|
|
latest_state,
|
|
response.parsed_body["missing_update"],
|
|
)
|
|
expect(applied[:text]).to eq("Server edit")
|
|
end
|
|
|
|
it "accepts updates without state_vector parameter" do
|
|
latest_state = latest_state_for(post1)
|
|
new_text = "Updated without state vector"
|
|
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, new_text),
|
|
}
|
|
|
|
expect(response.status).to eq(200)
|
|
end
|
|
|
|
it "accepts updates when client is ahead of server" do
|
|
latest_state = latest_state_for(post1)
|
|
update = DiscourseSharedEdits::Yjs.update_from_state(latest_state, "Intermediate edit")
|
|
client_state = DiscourseSharedEdits::Yjs.apply_update(latest_state, update)[:state]
|
|
client_sv = DiscourseSharedEdits::Yjs.get_state_vector(client_state)
|
|
client_sv_b64 = Base64.strict_encode64(client_sv.pack("C*"))
|
|
|
|
new_text = "Edit from client ahead of server"
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, new_text),
|
|
state_vector: client_sv_b64,
|
|
}
|
|
|
|
expect(response.status).to eq(200)
|
|
end
|
|
|
|
it "rejects invalid state vectors" do
|
|
latest_state = latest_state_for(post1)
|
|
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, "Client update"),
|
|
state_vector: "invalid!!!",
|
|
}
|
|
|
|
expect(response.status).to eq(400)
|
|
expect(response.parsed_body["error"]).to eq(
|
|
DiscourseSharedEdits::Protocol::Errors::INVALID_UPDATE,
|
|
)
|
|
end
|
|
end
|
|
|
|
it "rejects recovery text when state is healthy" do
|
|
initial_count = SharedEditRevision.where(post_id: post1.id).count
|
|
initial_state = latest_state_for(post1)
|
|
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
recovery_text: "attempted overwrite from healthy state",
|
|
}
|
|
|
|
expect(response.status).to eq(409)
|
|
expect(response.parsed_body["error"]).to eq(
|
|
DiscourseSharedEdits::Protocol::Errors::RECOVERY_NOT_NEEDED,
|
|
)
|
|
expect(SharedEditRevision.where(post_id: post1.id).count).to eq(initial_count)
|
|
expect(latest_state_for(post1)).to eq(initial_state)
|
|
end
|
|
|
|
it "rejects whitespace-only recovery text before recovery path" do
|
|
# Corrupt state to enter recovery path
|
|
revision = SharedEditRevision.where(post_id: post1.id).first
|
|
revision.update_column(:raw, Base64.strict_encode64("corrupted data"))
|
|
|
|
initial_count = SharedEditRevision.where(post_id: post1.id).count
|
|
|
|
put "/shared_edits/p/#{post1.id}", params: { client_id: "abc", recovery_text: " " }
|
|
|
|
expect(response.status).to eq(400)
|
|
expect(SharedEditRevision.where(post_id: post1.id).count).to eq(initial_count)
|
|
end
|
|
|
|
it "recovers state when client provides recovery text" do
|
|
# Corrupt the state
|
|
revision = SharedEditRevision.where(post_id: post1.id).first
|
|
revision.update_column(:raw, Base64.strict_encode64("corrupted data"))
|
|
|
|
# Client sends recovery text
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
recovery_text: "recovered content from client",
|
|
}
|
|
|
|
expect(response.status).to eq(200)
|
|
expect(response.parsed_body["error"]).to eq(
|
|
DiscourseSharedEdits::Protocol::Errors::STATE_RECOVERED_FROM_CLIENT,
|
|
)
|
|
expect(response.parsed_body["version"]).to be_present
|
|
end
|
|
|
|
it "returns 404 when shared edits not enabled on post" do
|
|
post1.custom_fields.delete(DiscourseSharedEdits::SHARED_EDITS_ENABLED)
|
|
post1.save_custom_fields
|
|
|
|
put "/shared_edits/p/#{post1.id}", params: { client_id: "abc", update: "test" }
|
|
|
|
expect(response.status).to eq(404)
|
|
end
|
|
|
|
it "returns 409 when shared edits are disabled mid-session" do
|
|
SharedEditRevision.where(post_id: post1.id).delete_all
|
|
|
|
latest_state = DiscourseSharedEdits::Yjs.state_from_text("test")[:state]
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, "new text"),
|
|
}
|
|
|
|
expect(response.status).to eq(409)
|
|
expect(response.parsed_body["error"]).to eq(
|
|
DiscourseSharedEdits::Protocol::Errors::NOT_INITIALIZED,
|
|
)
|
|
end
|
|
|
|
it "returns 403 when the user cannot edit the post" do
|
|
other_user = Fabricate(:user)
|
|
sign_in(other_user)
|
|
|
|
latest_state = latest_state_for(post1)
|
|
new_text = "Collaborative edit from another user"
|
|
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "other-client",
|
|
update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, new_text),
|
|
}
|
|
|
|
expect(response.status).to eq(403)
|
|
end
|
|
|
|
describe "rate limiting" do
|
|
# Use TL4 user for rate limiting tests since admins/mods bypass rate limits
|
|
# TL4 users can edit others' posts but don't bypass rate limiting
|
|
before { sign_in tl4_user }
|
|
|
|
it "allows requests within the rate limit" do
|
|
RateLimiter.enable
|
|
|
|
5.times do
|
|
latest_state = latest_state_for(post1)
|
|
new_text = "Edit #{SecureRandom.hex(4)}"
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, new_text),
|
|
}
|
|
expect(response.status).to eq(200)
|
|
end
|
|
ensure
|
|
RateLimiter.disable
|
|
end
|
|
|
|
it "returns 429 when rate limit is exceeded" do
|
|
RateLimiter.enable
|
|
|
|
# Exhaust the rate limit (120 per minute)
|
|
121.times do |i|
|
|
latest_state = latest_state_for(post1)
|
|
new_text = "Edit #{i}"
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, new_text),
|
|
}
|
|
end
|
|
|
|
expect(response.status).to eq(429)
|
|
expect(response.parsed_body["extras"]["wait_seconds"]).to be_present
|
|
ensure
|
|
RateLimiter.disable
|
|
end
|
|
|
|
it "rate limits per post independently" do
|
|
RateLimiter.enable
|
|
|
|
post2 = Fabricate(:post, user: tl4_user)
|
|
SharedEditRevision.toggle_shared_edits!(post2.id, true)
|
|
|
|
# Make requests to post1
|
|
latest_state = latest_state_for(post1)
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
update: DiscourseSharedEdits::Yjs.update_from_state(latest_state, "Edit post1"),
|
|
}
|
|
expect(response.status).to eq(200)
|
|
|
|
# Make requests to post2 - should not be affected by post1's rate limit
|
|
latest_state2 = SharedEditRevision.where(post_id: post2.id).order("version desc").first.raw
|
|
put "/shared_edits/p/#{post2.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
update: DiscourseSharedEdits::Yjs.update_from_state(latest_state2, "Edit post2"),
|
|
}
|
|
expect(response.status).to eq(200)
|
|
ensure
|
|
RateLimiter.disable
|
|
end
|
|
|
|
it "applies recovery rate limits even when recovery is not needed" do
|
|
RateLimiter.enable
|
|
|
|
21.times do
|
|
put "/shared_edits/p/#{post1.id}",
|
|
params: {
|
|
client_id: "abc",
|
|
recovery_text: "redundant recovery request",
|
|
}
|
|
end
|
|
|
|
expect(response.status).to eq(429)
|
|
ensure
|
|
RateLimiter.disable
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "#health" do
|
|
context "when admin" do
|
|
before do
|
|
sign_in admin
|
|
SharedEditRevision.toggle_shared_edits!(post1.id, true)
|
|
end
|
|
|
|
it "returns health status for a post" do
|
|
get "/shared_edits/p/#{post1.id}/health"
|
|
|
|
expect(response.status).to eq(200)
|
|
body = response.parsed_body
|
|
expect(body["healthy"]).to eq(true)
|
|
expect(body["state"]).to eq("initialized")
|
|
expect(body["current_text"]).to eq(post1.raw)
|
|
end
|
|
|
|
it "detects corrupted state" do
|
|
revision = SharedEditRevision.where(post_id: post1.id).first
|
|
revision.update_column(:raw, Base64.strict_encode64("corrupted"))
|
|
|
|
get "/shared_edits/p/#{post1.id}/health"
|
|
|
|
expect(response.status).to eq(200)
|
|
body = response.parsed_body
|
|
expect(body["healthy"]).to eq(false)
|
|
expect(body["errors"]).not_to be_empty
|
|
end
|
|
|
|
it "returns not_initialized for posts without shared edits" do
|
|
post2 = Fabricate(:post)
|
|
|
|
get "/shared_edits/p/#{post2.id}/health"
|
|
|
|
expect(response.status).to eq(200)
|
|
body = response.parsed_body
|
|
expect(body["state"]).to eq("not_initialized")
|
|
end
|
|
end
|
|
|
|
context "when regular user" do
|
|
before { sign_in user }
|
|
|
|
it "returns 403" do
|
|
get "/shared_edits/p/#{post1.id}/health"
|
|
expect(response.status).to eq(403)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "#recover" do
|
|
context "when admin" do
|
|
before do
|
|
sign_in admin
|
|
SharedEditRevision.toggle_shared_edits!(post1.id, true)
|
|
end
|
|
|
|
it "recovers corrupted state" do
|
|
revision = SharedEditRevision.where(post_id: post1.id).first
|
|
revision.update_column(:raw, Base64.strict_encode64("corrupted"))
|
|
|
|
messages =
|
|
MessageBus.track_publish("/shared_edits/#{post1.id}") do
|
|
post "/shared_edits/p/#{post1.id}/recover"
|
|
end
|
|
|
|
expect(response.status).to eq(200)
|
|
body = response.parsed_body
|
|
expect(body["success"]).to eq(true)
|
|
|
|
expect(messages.length).to eq(1)
|
|
expect(messages.first.data[:action]).to eq("resync")
|
|
end
|
|
|
|
it "refuses to recover healthy state without force" do
|
|
post "/shared_edits/p/#{post1.id}/recover"
|
|
|
|
expect(response.status).to eq(422)
|
|
body = response.parsed_body
|
|
expect(body["success"]).to eq(false)
|
|
expect(body["message"]).to include("healthy")
|
|
end
|
|
|
|
it "recovers healthy state with force parameter" do
|
|
post "/shared_edits/p/#{post1.id}/recover", params: { force: "true" }
|
|
|
|
expect(response.status).to eq(200)
|
|
body = response.parsed_body
|
|
expect(body["success"]).to eq(true)
|
|
end
|
|
|
|
it "returns 404 for non-existent post" do
|
|
post "/shared_edits/p/999999/recover"
|
|
expect(response.status).to eq(404)
|
|
end
|
|
end
|
|
|
|
context "when regular user" do
|
|
before { sign_in user }
|
|
|
|
it "returns 403" do
|
|
post "/shared_edits/p/#{post1.id}/recover"
|
|
expect(response.status).to eq(403)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "#latest with automatic recovery" do
|
|
before do
|
|
sign_in admin
|
|
SharedEditRevision.toggle_shared_edits!(post1.id, true)
|
|
end
|
|
|
|
it "automatically recovers corrupted state on access" do
|
|
revision = SharedEditRevision.where(post_id: post1.id).order("version desc").first
|
|
revision.update_column(:raw, Base64.strict_encode64("corrupted"))
|
|
|
|
get "/shared_edits/p/#{post1.id}"
|
|
|
|
expect(response.status).to eq(200)
|
|
body = response.parsed_body
|
|
expect(body["raw"]).to eq(post1.raw)
|
|
expect(body["version"]).to eq(2)
|
|
end
|
|
|
|
it "does not bypass recovery rate limiting on repeated auto-recoveries" do
|
|
revision = SharedEditRevision.where(post_id: post1.id).order("version desc").first
|
|
revision.update_column(:raw, Base64.strict_encode64("corrupted once"))
|
|
|
|
get "/shared_edits/p/#{post1.id}"
|
|
expect(response.status).to eq(200)
|
|
|
|
revision = SharedEditRevision.where(post_id: post1.id).order("version desc").first
|
|
revision.update_column(:raw, Base64.strict_encode64("corrupted twice"))
|
|
|
|
get "/shared_edits/p/#{post1.id}"
|
|
expect(response.status).to eq(403)
|
|
end
|
|
end
|
|
|
|
describe "#reset" do
|
|
def latest_state_for(post)
|
|
SharedEditRevision.where(post_id: post.id).order("version desc").limit(1).pluck(:raw).first
|
|
end
|
|
|
|
context "when admin" do
|
|
before do
|
|
sign_in admin
|
|
SharedEditRevision.toggle_shared_edits!(post1.id, true)
|
|
end
|
|
|
|
it "resets history and notifies clients" do
|
|
# Make some edits to build up history
|
|
state = latest_state_for(post1)
|
|
update = DiscourseSharedEdits::Yjs.update_from_state(state, "Edit 1")
|
|
SharedEditRevision.revise!(
|
|
post_id: post1.id,
|
|
user_id: admin.id,
|
|
client_id: "test",
|
|
update: update,
|
|
)
|
|
|
|
state = latest_state_for(post1)
|
|
update = DiscourseSharedEdits::Yjs.update_from_state(state, "Edit 2")
|
|
SharedEditRevision.revise!(
|
|
post_id: post1.id,
|
|
user_id: admin.id,
|
|
client_id: "test",
|
|
update: update,
|
|
)
|
|
|
|
expect(SharedEditRevision.where(post_id: post1.id).count).to eq(3)
|
|
|
|
messages =
|
|
MessageBus.track_publish("/shared_edits/#{post1.id}") do
|
|
post "/shared_edits/p/#{post1.id}/reset"
|
|
end
|
|
|
|
expect(response.status).to eq(200)
|
|
body = response.parsed_body
|
|
expect(body["success"]).to eq(true)
|
|
expect(body["version"]).to eq(1)
|
|
|
|
expect(SharedEditRevision.where(post_id: post1.id).count).to eq(1)
|
|
|
|
expect(messages.length).to eq(1)
|
|
expect(messages.first.data[:action]).to eq("resync")
|
|
end
|
|
|
|
it "commits pending changes before reset" do
|
|
state = latest_state_for(post1)
|
|
new_content = "New content from edit"
|
|
update = DiscourseSharedEdits::Yjs.update_from_state(state, new_content)
|
|
SharedEditRevision.revise!(
|
|
post_id: post1.id,
|
|
user_id: admin.id,
|
|
client_id: "test",
|
|
update: update,
|
|
)
|
|
|
|
post "/shared_edits/p/#{post1.id}/reset"
|
|
|
|
expect(response.status).to eq(200)
|
|
post1.reload
|
|
expect(post1.raw).to eq(new_content)
|
|
end
|
|
|
|
it "does not reset history when pending changes cannot be committed" do
|
|
state = latest_state_for(post1)
|
|
update = DiscourseSharedEdits::Yjs.update_from_state(state, "pending change before reset")
|
|
SharedEditRevision.revise!(
|
|
post_id: post1.id,
|
|
user_id: admin.id,
|
|
client_id: "test",
|
|
update: update,
|
|
)
|
|
|
|
revision_count = SharedEditRevision.where(post_id: post1.id).count
|
|
post1.topic.update!(closed: true)
|
|
|
|
post "/shared_edits/p/#{post1.id}/reset"
|
|
|
|
expect(response.status).to eq(422)
|
|
expect(response.parsed_body["error"]).to eq(
|
|
DiscourseSharedEdits::Protocol::Errors::RESET_FAILED,
|
|
)
|
|
expect(SharedEditRevision.where(post_id: post1.id).count).to eq(revision_count)
|
|
end
|
|
end
|
|
|
|
context "when regular user" do
|
|
before { sign_in user }
|
|
|
|
it "returns 403" do
|
|
post "/shared_edits/p/#{post1.id}/reset"
|
|
expect(response.status).to eq(403)
|
|
end
|
|
end
|
|
end
|
|
end
|