diff --git a/app/views/books/_settings_sidebar.html.erb b/app/views/books/_settings_sidebar.html.erb index 6bc1bfd12..1b3dca18c 100644 --- a/app/views/books/_settings_sidebar.html.erb +++ b/app/views/books/_settings_sidebar.html.erb @@ -9,6 +9,7 @@ x-data="{ settingsTab: 'privacy', selectedPrivacy: '<%= @book.privacy || 'private' %>', + inheritedPublic: <%= @book.universe.present? && @book.universe.privacy == 'public' %>, isDark: document.documentElement.classList.contains('dark'), isLoading: false, showToast: false, @@ -95,9 +96,9 @@
-
- +

@@ -107,7 +108,9 @@

- Book is + Book is

@@ -154,16 +157,32 @@ <% end %> + <% if @book.universe.present? && @book.universe.privacy == 'public' %> +
+
+ public +

+ This book belongs to + <%= link_to @book.universe.name, universe_path(@book.universe), class: 'font-semibold underline' %>, + which is public. Anyone can view this book and its chapters while that universe stays public, + even with the book set to Private. Make the universe private to restrict access. +

+
+
+ <% end %> + <% private_chapter_count = @book.documents.where("documents.privacy IS NULL OR documents.privacy != 'public'").count %> <% if private_chapter_count > 0 %> -
visibility

This book contains <%= pluralize(private_chapter_count, 'private chapter') %>. - While the book is public, anyone who can view it can also read those chapters. - Making the book private again restores their privacy. + Anyone who can view this book can also read those chapters. + Making the book private again restores their privacy. + Making its universe private restores their privacy.

diff --git a/app/views/books/show.html.erb b/app/views/books/show.html.erb index c2b31cd5b..66f737bc0 100644 --- a/app/views/books/show.html.erb +++ b/app/views/books/show.html.erb @@ -158,6 +158,10 @@

<% if @book.privacy == 'public' %> You are currently viewing the public layout. This is what readers will see. + <% elsif @book.effectively_public? %> + You are currently viewing the public layout. This book is set to private, but it belongs to the public + universe <%= link_to @book.universe.name, universe_path(@book.universe), class: "underline" %>, so anyone + can see it — this is what readers will see. <% else %> You are currently viewing the reader layout. This book is private, so only you and collaborators can see it. <% end %> diff --git a/test/authorizers/book_authorizer_test.rb b/test/authorizers/book_authorizer_test.rb new file mode 100644 index 000000000..130b557c9 --- /dev/null +++ b/test/authorizers/book_authorizer_test.rb @@ -0,0 +1,87 @@ +require 'test_helper' + +class BookAuthorizerTest < ActiveSupport::TestCase + setup do + @author = User.first + @stranger = User.last + assert_not_equal(@author.id, @stranger.id, "Test problem: author and stranger need to be different users!") + end + + def anonymous + User.new + end + + test "a book created without an explicit privacy is private" do + book = Book.create!(user: @author, name: 'Untitled Book') + + assert_equal 'private', book.privacy + assert_not book.effectively_public? + assert_not book.public_content? + assert book.private_content? + end + + test "a book created without a universe is private" do + book = Book.create!(user: @author, name: 'Untitled Book') + + assert_nil book.universe + assert @author.can_read?(book) + assert_not @stranger.can_read?(book) + assert_not anonymous.can_read?(book) + end + + test "a private book with no universe is excluded from the is_public scope" do + # Guards the LEFT OUTER JOIN in HasPrivacy#is_public: a universe-less book + # joins against a NULL universes.privacy, which must not match 'public'. + book = Book.create!(user: @author, name: 'Untitled Book', privacy: 'private') + + assert_not_includes Book.is_public, book + end + + test "a public book with no universe is readable by anyone" do + book = Book.create!(user: @author, name: 'My Book', privacy: 'public') + + assert book.effectively_public? + assert @stranger.can_read?(book) + assert anonymous.can_read?(book) + assert_includes Book.is_public, book + end + + test "a private book in a private universe is not readable by others" do + universe = Universe.create!(user: @author, name: 'Private Universe', privacy: 'private') + book = Book.create!(user: @author, name: 'My Book', privacy: 'private', universe: universe) + + assert_not book.effectively_public? + assert_not @stranger.can_read?(book) + assert_not anonymous.can_read?(book) + assert_not_includes Book.is_public, book + end + + test "a private book in a public universe is readable by anyone" do + # Books inherit their universe's visibility: a public universe makes its + # books readable even when the book's own privacy is still 'private'. + universe = Universe.create!(user: @author, name: 'Public Universe', privacy: 'public') + book = Book.create!(user: @author, name: 'My Book', privacy: 'private', universe: universe) + + assert_equal 'private', book.privacy + assert book.effectively_public? + assert @stranger.can_read?(book) + assert anonymous.can_read?(book) + assert_includes Book.is_public, book + end + + test "a public book grants read access only, not update or delete" do + book = Book.create!(user: @author, name: 'My Book', privacy: 'public') + + assert_not @stranger.can_update?(book) + assert_not @stranger.can_delete?(book) + assert @author.can_update?(book) + assert @author.can_delete?(book) + end + + test "another user's private book is not readable" do + book = Book.create!(user: @stranger, name: 'Not My Book', privacy: 'private') + + assert_not @author.can_read?(book) + assert_not anonymous.can_read?(book) + end +end diff --git a/test/controllers/books_privacy_notice_test.rb b/test/controllers/books_privacy_notice_test.rb new file mode 100644 index 000000000..1611fe215 --- /dev/null +++ b/test/controllers/books_privacy_notice_test.rb @@ -0,0 +1,62 @@ +require 'test_helper' + +class BooksPrivacyNoticeTest < ActionDispatch::IntegrationTest + include Devise::Test::IntegrationHelpers + + setup do + @user = users(:one) + sign_in @user + end + + test "edit page warns that a private book in a public universe is publicly visible" do + universe = Universe.create!(user: @user, name: 'Public Universe', privacy: 'public') + book = Book.create!(user: @user, name: 'My Book', privacy: 'private', universe: universe) + + get edit_book_path(book) + + assert_response :success + assert_match 'Make the universe private to restrict access', response.body + assert_match 'inheritedPublic: true', response.body + end + + test "edit page shows no inherited-visibility warning for a book in a private universe" do + universe = Universe.create!(user: @user, name: 'Private Universe', privacy: 'private') + book = Book.create!(user: @user, name: 'My Book', privacy: 'private', universe: universe) + + get edit_book_path(book) + + assert_response :success + assert_no_match 'Make the universe private to restrict access', response.body + assert_match 'inheritedPublic: false', response.body + end + + test "edit page shows no inherited-visibility warning for a book with no universe" do + book = Book.create!(user: @user, name: 'My Book', privacy: 'private') + + get edit_book_path(book) + + assert_response :success + assert_no_match 'Make the universe private to restrict access', response.body + assert_match 'inheritedPublic: false', response.body + end + + test "show page tells the author when a private book is public through its universe" do + universe = Universe.create!(user: @user, name: 'Public Universe', privacy: 'public') + book = Book.create!(user: @user, name: 'My Book', privacy: 'private', universe: universe) + + get book_path(book) + + assert_response :success + assert_match 'belongs to the public', response.body + end + + test "show page tells the author a genuinely private book is private" do + book = Book.create!(user: @user, name: 'My Book', privacy: 'private') + + get book_path(book) + + assert_response :success + assert_match 'This book is private, so only you and collaborators can see it', response.body + assert_no_match 'belongs to the public', response.body + end +end