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,52 @@
|
||||
require "test_helper"
|
||||
|
||||
class AuthFlowTest < ActionDispatch::IntegrationTest
|
||||
include ActionMailer::TestHelper
|
||||
|
||||
test "requesting a link sends a mail, same answer for any address" do
|
||||
assert_enqueued_emails 1 do
|
||||
post "/api/auth/magic_link", params: { email: "anna@example.com", return_to: "/d/x" }, as: :json
|
||||
end
|
||||
assert_response :success
|
||||
assert_equal({ "ok" => true }, json)
|
||||
end
|
||||
|
||||
test "mail link: GET only shows a confirm page, POST signs in and redirects" do
|
||||
link = MagicLink.issue!(email: "anna@example.com", return_to: "/d/123")
|
||||
get "/auth/verify", params: { token: link.token }
|
||||
assert_response :success
|
||||
assert_nil link.reload.used_at, "a GET (mail scanner) must not use the link"
|
||||
|
||||
post "/auth/verify", params: { token: link.token }
|
||||
assert_redirected_to "/d/123"
|
||||
get "/api/me"
|
||||
assert_equal "anna@example.com", json.dig("user", "email")
|
||||
|
||||
post "/auth/verify", params: { token: link.token }
|
||||
assert_response :not_found
|
||||
end
|
||||
|
||||
test "code sign-in and logout" do
|
||||
user = create_user
|
||||
sign_in user
|
||||
get "/api/me"
|
||||
assert_equal user.id, json.dig("user", "id")
|
||||
|
||||
delete "/api/auth/session"
|
||||
get "/api/me"
|
||||
assert_nil json["user"]
|
||||
end
|
||||
|
||||
test "wrong code" do
|
||||
MagicLink.issue!(email: "anna@example.com")
|
||||
post "/api/auth/code", params: { email: "anna@example.com", code: "abc" }, as: :json
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "the app page embeds the signed-in user" do
|
||||
sign_in create_user
|
||||
get "/"
|
||||
assert_response :success
|
||||
assert_includes response.body, "anna@example.com"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,111 @@
|
||||
require "test_helper"
|
||||
|
||||
class DocumentsApiTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@anna = create_user("anna@example.com")
|
||||
@bob = create_user("bob@example.com")
|
||||
@doc = create_document(@anna)
|
||||
end
|
||||
|
||||
def put_content(doc, base_version, headers: {})
|
||||
patch "/api/documents/#{doc.id}", params: { content: DOC_JSON, base_version: }, headers:, as: :json
|
||||
end
|
||||
|
||||
test "listing and creating need an account" do
|
||||
get "/api/documents"
|
||||
assert_response :unauthorized
|
||||
post "/api/documents", params: { title: "x", content: DOC_JSON }, as: :json
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "create (upload), list, read" do
|
||||
sign_in @anna
|
||||
post "/api/documents", params: { title: "Hochgeladen", content: DOC_JSON }, as: :json
|
||||
assert_response :created
|
||||
id = json.dig("document", "id")
|
||||
|
||||
get "/api/documents"
|
||||
assert_equal [ id, @doc.id ].sort, json["documents"].map { _1["id"] }.sort
|
||||
|
||||
get "/api/documents/#{id}"
|
||||
assert_equal DOC_JSON, json.dig("document", "content")
|
||||
assert_equal "owner", json.dig("document", "role")
|
||||
end
|
||||
|
||||
test "someone else's document is a 404" do
|
||||
sign_in @bob
|
||||
get "/api/documents/#{@doc.id}"
|
||||
assert_response :not_found
|
||||
end
|
||||
|
||||
test "save with version check; stale base_version is a conflict" do
|
||||
sign_in @anna
|
||||
put_content(@doc, 1)
|
||||
assert_response :success
|
||||
assert_equal 2, json.dig("document", "version")
|
||||
|
||||
put_content(@doc, 1)
|
||||
assert_response :conflict
|
||||
assert_equal 2, json["version"]
|
||||
end
|
||||
|
||||
test "known_version returns metadata only when unchanged" do
|
||||
sign_in @anna
|
||||
get "/api/documents/#{@doc.id}", params: { known_version: 1 }
|
||||
assert json["unchanged"]
|
||||
assert_nil json.dig("document", "content")
|
||||
end
|
||||
|
||||
test "edit lock blocks saves from another tab" do
|
||||
sign_in @anna
|
||||
post "/api/documents/#{@doc.id}/lock", headers: { "X-Client-Id" => "tab-a" }
|
||||
assert json.dig("lock", "mine")
|
||||
|
||||
post "/api/documents/#{@doc.id}/lock", headers: { "X-Client-Id" => "tab-b" }
|
||||
assert_not json.dig("lock", "mine")
|
||||
put_content(@doc, 1, headers: { "X-Client-Id" => "tab-b" })
|
||||
assert_response :locked
|
||||
|
||||
delete "/api/documents/#{@doc.id}/lock", headers: { "X-Client-Id" => "tab-a" }
|
||||
put_content(@doc, 1, headers: { "X-Client-Id" => "tab-b" })
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "share link without account: read with token; viewer cannot write" do
|
||||
link = @doc.share_links.create!(role: :viewer, created_by: @anna)
|
||||
headers = { "X-Share-Token" => link.token }
|
||||
|
||||
post "/api/documents/#{@doc.id}/join", params: { token: link.token }, as: :json
|
||||
assert_equal "viewer", json.dig("document", "role")
|
||||
get "/api/documents/#{@doc.id}", headers: headers
|
||||
assert_response :success
|
||||
put_content(@doc, 1, headers:)
|
||||
assert_response :forbidden
|
||||
|
||||
get "/api/documents/#{@doc.id}", headers: { "X-Share-Token" => "wrong" }
|
||||
assert_response :not_found
|
||||
end
|
||||
|
||||
test "share link with account: joining adds it to the list" do
|
||||
link = @doc.share_links.create!(role: :editor, created_by: @anna)
|
||||
sign_in @bob
|
||||
post "/api/documents/#{@doc.id}/join", params: { token: link.token }, as: :json
|
||||
get "/api/documents"
|
||||
assert_equal [ [ @doc.id, "editor" ] ], json["documents"].map { [ _1["id"], _1["role"] ] }
|
||||
put_content(@doc, 1)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "owner deletes; a member only leaves" do
|
||||
DocumentMembership.grant!(document: @doc, user: @bob, role: :editor, source: :invite)
|
||||
sign_in @bob
|
||||
delete "/api/documents/#{@doc.id}"
|
||||
assert_response :no_content
|
||||
assert Document.exists?(@doc.id)
|
||||
assert_nil @doc.role_for(user: @bob)
|
||||
|
||||
sign_in @anna
|
||||
delete "/api/documents/#{@doc.id}"
|
||||
assert_not Document.exists?(@doc.id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,55 @@
|
||||
require "test_helper"
|
||||
|
||||
class SharesApiTest < ActionDispatch::IntegrationTest
|
||||
include ActionMailer::TestHelper
|
||||
|
||||
setup do
|
||||
@anna = create_user("anna@example.com")
|
||||
@bob = create_user("bob@example.com")
|
||||
@doc = create_document(@anna)
|
||||
end
|
||||
|
||||
test "only the owner manages sharing" do
|
||||
DocumentMembership.grant!(document: @doc, user: @bob, role: :editor, source: :invite)
|
||||
sign_in @bob
|
||||
get "/api/documents/#{@doc.id}/share"
|
||||
assert_response :not_found
|
||||
end
|
||||
|
||||
test "one active link per role; a new one revokes the old" do
|
||||
sign_in @anna
|
||||
post "/api/documents/#{@doc.id}/share/links", params: { role: "editor" }, as: :json
|
||||
first = json["links"].first["token"]
|
||||
post "/api/documents/#{@doc.id}/share/links", params: { role: "editor" }, as: :json
|
||||
assert_equal 1, json["links"].size
|
||||
assert_not_equal first, json["links"].first["token"]
|
||||
assert_nil @doc.role_for(token: first)
|
||||
|
||||
delete "/api/documents/#{@doc.id}/share/links/#{json['links'].first['id']}"
|
||||
assert_empty json["links"]
|
||||
end
|
||||
|
||||
test "invite by mail, change role, remove" do
|
||||
sign_in @anna
|
||||
assert_enqueued_emails 1 do
|
||||
post "/api/documents/#{@doc.id}/share/invitations", params: { email: "bob@example.com", role: "viewer" }, as: :json
|
||||
end
|
||||
assert_equal [ "bob@example.com" ], json["invitations"].map { _1["email"] }
|
||||
|
||||
MagicLink.invitation.last.redeem!
|
||||
get "/api/documents/#{@doc.id}/share"
|
||||
assert_equal [ [ "bob@example.com", "viewer", "invite" ] ], json["members"].map { _1.values_at("email", "role", "source") }
|
||||
assert_empty json["invitations"]
|
||||
|
||||
patch "/api/documents/#{@doc.id}/share/members/#{@bob.id}", params: { role: "editor" }, as: :json
|
||||
assert_equal :editor, @doc.role_for(user: @bob)
|
||||
delete "/api/documents/#{@doc.id}/share/members/#{@bob.id}"
|
||||
assert_nil @doc.role_for(user: @bob)
|
||||
end
|
||||
|
||||
test "cannot invite yourself" do
|
||||
sign_in @anna
|
||||
post "/api/documents/#{@doc.id}/share/invitations", params: { email: "anna@example.com", role: "viewer" }, as: :json
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user