Add Rails backend: accounts via magic link, server documents, sharing
The app becomes a Rails 8 application; the Vue frontend moves to app/frontend and is served through vite_rails. - Without an account documents stay in the browser (IndexedDB). Sign-in is by magic link or the 6-digit code from the same mail; afterwards local documents can be uploaded into the account. - Server documents (/d/<id>) with versioned saves and a single-editor lock per browser tab; others see them read-only and follow by polling. - Sharing via link (works without an account) and via e-mail invitation, roles viewer/editor; the owner manages links and people. - Store gains a read-only mode that blocks all content changes. - Solid Queue, Cache and Cable share the one PostgreSQL database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
require "test_helper"
|
||||
|
||||
class DocumentTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@owner = create_user("owner@example.com")
|
||||
@doc = create_document(@owner)
|
||||
end
|
||||
|
||||
test "rejects content that is not a numolus document" do
|
||||
doc = @owner.owned_documents.build(title: "x", content: "[1,2]")
|
||||
assert_not doc.valid?
|
||||
doc.content = "{nope"
|
||||
assert_not doc.valid?
|
||||
end
|
||||
|
||||
test "role_for: owner, member, link token, and the strongest grant wins" do
|
||||
bob = create_user("bob@example.com")
|
||||
assert_equal :owner, @doc.role_for(user: @owner)
|
||||
assert_nil @doc.role_for(user: bob)
|
||||
|
||||
viewer_link = @doc.share_links.create!(role: :viewer, created_by: @owner)
|
||||
editor_link = @doc.share_links.create!(role: :editor, created_by: @owner)
|
||||
assert_equal :viewer, @doc.role_for(token: viewer_link.token)
|
||||
|
||||
DocumentMembership.grant!(document: @doc, user: bob, role: :viewer, source: :invite)
|
||||
assert_equal :viewer, @doc.role_for(user: bob)
|
||||
assert_equal :editor, @doc.role_for(user: bob, token: editor_link.token)
|
||||
|
||||
editor_link.revoke!
|
||||
assert_nil @doc.role_for(token: editor_link.token)
|
||||
end
|
||||
|
||||
test "revoking a link removes access gained through it, but not invitations" do
|
||||
carl = create_user("carl@example.com")
|
||||
dora = create_user("dora@example.com")
|
||||
link = @doc.share_links.create!(role: :editor, created_by: @owner)
|
||||
DocumentMembership.grant!(document: @doc, user: carl, role: :editor, source: :link, share_link: link)
|
||||
DocumentMembership.grant!(document: @doc, user: dora, role: :editor, source: :link, share_link: link)
|
||||
DocumentMembership.grant!(document: @doc, user: dora, role: :viewer, source: :invite)
|
||||
|
||||
link.revoke!
|
||||
assert_nil @doc.role_for(user: carl)
|
||||
assert_equal :viewer, @doc.role_for(user: dora)
|
||||
end
|
||||
|
||||
test "a link never weakens a membership" do
|
||||
bob = create_user("bob@example.com")
|
||||
DocumentMembership.grant!(document: @doc, user: bob, role: :editor, source: :invite)
|
||||
DocumentMembership.grant!(document: @doc, user: bob, role: :viewer, source: :link)
|
||||
assert_equal :editor, @doc.role_for(user: bob)
|
||||
end
|
||||
|
||||
test "edit lock: one holder at a time, expires without heartbeat" do
|
||||
assert @doc.acquire_lock!("1:tab-a", "Anna")
|
||||
assert @doc.acquire_lock!("1:tab-a", "Anna"), "refreshing own lock"
|
||||
assert_not @doc.acquire_lock!("2:tab-b", "Bob")
|
||||
assert @doc.reload.locked_by_other?("2:tab-b")
|
||||
|
||||
travel Document::LOCK_TTL + 1.second do
|
||||
assert @doc.acquire_lock!("2:tab-b", "Bob")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,49 @@
|
||||
require "test_helper"
|
||||
|
||||
class MagicLinkTest < ActiveSupport::TestCase
|
||||
test "stores only digests and redeems once, creating the user" do
|
||||
link = MagicLink.issue!(email: " New@Example.com ")
|
||||
assert_equal "new@example.com", link.email
|
||||
assert_not_equal link.token, link.token_digest
|
||||
assert_equal link, MagicLink.find_by_token(link.token)
|
||||
|
||||
user = link.redeem!
|
||||
assert_equal "new@example.com", user.email
|
||||
assert_nil MagicLink.find_by_token(link.token), "used"
|
||||
end
|
||||
|
||||
test "expires" do
|
||||
link = MagicLink.issue!(email: "a@example.com")
|
||||
travel MagicLink::LOGIN_TTL + 1.second do
|
||||
assert_nil MagicLink.find_by_token(link.token)
|
||||
end
|
||||
end
|
||||
|
||||
test "code: wrong attempts burn the link" do
|
||||
link = MagicLink.issue!(email: "a@example.com")
|
||||
wrong = link.code == "000000" ? "111111" : "000000"
|
||||
MagicLink::MAX_CODE_ATTEMPTS.times { assert_nil MagicLink.find_by_code("a@example.com", wrong) }
|
||||
assert_nil MagicLink.find_by_code("a@example.com", link.code)
|
||||
end
|
||||
|
||||
test "code accepts formatting and the right email only" do
|
||||
link = MagicLink.issue!(email: "a@example.com")
|
||||
assert_equal link, MagicLink.find_by_code("A@example.com", link.code.chars.each_slice(3).map(&:join).join(" "))
|
||||
assert_nil MagicLink.find_by_code("b@example.com", link.code)
|
||||
end
|
||||
|
||||
test "return_to only allows local paths" do
|
||||
assert_equal "/d/abc", MagicLink.safe_path("/d/abc")
|
||||
assert_nil MagicLink.safe_path("https://evil.example")
|
||||
assert_nil MagicLink.safe_path("//evil.example")
|
||||
end
|
||||
|
||||
test "invitation grants the document on redeem" do
|
||||
owner = create_user("owner@example.com")
|
||||
doc = create_document(owner)
|
||||
link = MagicLink.issue!(email: "guest@example.com", purpose: :invitation, document: doc, role: :editor, invited_by: owner)
|
||||
user = link.redeem!
|
||||
assert_equal :editor, doc.role_for(user:)
|
||||
assert_equal "/d/#{doc.id}", link.destination
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user