discourse-shared-edits/spec/requests/revision_controller_spec.rb
Sam 50d9317272
FEATURE: yjs-based collaborative editing with state recovery and cursor sync
Major overhaul of the shared edits plugin to improve reliability, robustness,
and developer experience:

**Backend**
- Extract Revise service for cleaner controller orchestration
- Add StateValidator for base64/Yjs safety, corruption detection, and recovery
- Centralize protocol constants (Ruby + JS) to avoid hardcoded strings
- Add state hash sync verification and vector validation endpoints
- Harden security (guardian checks), error handling, and resource cleanup
- Resize shared edit columns migration; add state_hash column

**Frontend**
- Decompose shared-edit-manager into focused modules: yjs-document,
  markdown-sync, rich-mode-sync, network-manager, encoding-utils
- Add cursor overlay and caret coordinate tracking for selection sharing
- Add ProseMirror extension for rich-mode collaborative editing
- Cache-busted Yjs bundle loading via hashed filenames
- Fix scroll drift during sync

**Testing & Tooling**
- Extensive new specs: state_validator, revision_controller, model, revise service
- New Ember acceptance tests: cursor, lifecycle, sync flows
- Add support scripts: fake_writer (Playwright), state_corruptor, debug_recovery
- Add support/lint wrapper for full CI lint suite
- Update dependencies and rebuild Yjs/y-prosemirror bundles
2026-02-13 11:34:52 +11:00

1061 lines
34 KiB
Ruby

# frozen_string_literal: true
RSpec.describe DiscourseSharedEdits::RevisionController do
fab!(:user)
fab!(:tl4_user) { Fabricate(:user, trust_level: TrustLevel[4]) }
fab!(:post1) { Fabricate(:post, user: user, raw: "Hello World, testing shared edits") }
fab!(:admin)
fab!(: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 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
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