discourse-shared-edits/spec/requests/revision_controller_spec.rb
Sam 4e51a4e231
Some checks failed
Discourse Plugin / ci (push) Has been cancelled
FEATURE: allow shared edits to be enabled on any group (#163)
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>
2026-06-25 16:28:07 +10:00

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