Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions app/views/books/_settings_sidebar.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -95,9 +96,9 @@

<!-- Current Status Banner -->
<div class="bg-white dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700 p-3 mb-4 flex items-center">
<div :class="selectedPrivacy === 'public' ? (isDark ? 'text-emerald-400' : 'text-emerald-600') : (isDark ? 'text-gray-400' : 'text-gray-600')"
<div :class="(selectedPrivacy === 'public' || inheritedPublic) ? (isDark ? 'text-emerald-400' : 'text-emerald-600') : (isDark ? 'text-gray-400' : 'text-gray-600')"
class="mr-3 transition-colors duration-200">
<i class="material-icons" x-text="selectedPrivacy === 'public' ? 'public' : 'lock'"></i>
<i class="material-icons" x-text="(selectedPrivacy === 'public' || inheritedPublic) ? 'public' : 'lock'"></i>
</div>
<div class="flex-1">
<h4 class="text-xs font-semibold text-gray-900 dark:text-white flex items-center">
Expand All @@ -107,7 +108,9 @@
</div>
</h4>
<p class="text-[11px] text-gray-600 dark:text-gray-400 mt-0.5">
Book is <span class="font-bold" :class="selectedPrivacy === 'public' ? 'text-emerald-600 dark:text-emerald-400' : ''" x-text="selectedPrivacy.charAt(0).toUpperCase() + selectedPrivacy.slice(1)"></span>
Book is <span class="font-bold"
:class="(selectedPrivacy === 'public' || inheritedPublic) ? 'text-emerald-600 dark:text-emerald-400' : ''"
x-text="selectedPrivacy === 'public' ? 'Public' : (inheritedPublic ? 'Public (through its universe)' : 'Private')"></span>
</p>
</div>
</div>
Expand Down Expand Up @@ -154,16 +157,32 @@
</label>
<% end %>

<% if @book.universe.present? && @book.universe.privacy == 'public' %>
<div x-show="selectedPrivacy !== 'public'"
class="mt-3 border border-amber-300 dark:border-amber-800 rounded-lg p-3 bg-amber-50 dark:bg-amber-900 bg-opacity-100 dark:bg-opacity-30">
<div class="flex items-start">
<i class="material-icons text-amber-600 dark:text-amber-400 text-base mr-2">public</i>
<p class="text-xs text-amber-800 dark:text-amber-200 leading-snug">
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.
</p>
</div>
</div>
<% end %>

<% private_chapter_count = @book.documents.where("documents.privacy IS NULL OR documents.privacy != 'public'").count %>
<% if private_chapter_count > 0 %>
<div x-show="selectedPrivacy === 'public'"
<div x-show="selectedPrivacy === 'public' || inheritedPublic"
class="mt-3 border border-amber-300 dark:border-amber-800 rounded-lg p-3 bg-amber-50 dark:bg-amber-900 bg-opacity-100 dark:bg-opacity-30">
<div class="flex items-start">
<i class="material-icons text-amber-600 dark:text-amber-400 text-base mr-2">visibility</i>
<p class="text-xs text-amber-800 dark:text-amber-200 leading-snug">
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.
<span x-show="selectedPrivacy === 'public' && !inheritedPublic">Making the book private again restores their privacy.</span>
<span x-show="inheritedPublic">Making its universe private restores their privacy.</span>
</p>
</div>
</div>
Expand Down
4 changes: 4 additions & 0 deletions app/views/books/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@
<p class="text-xs text-blue-600/80 dark:text-blue-400/80 mb-4 leading-relaxed">
<% 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 %>
Expand Down
87 changes: 87 additions & 0 deletions test/authorizers/book_authorizer_test.rb
Original file line number Diff line number Diff line change
@@ -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
62 changes: 62 additions & 0 deletions test/controllers/books_privacy_notice_test.rb
Original file line number Diff line number Diff line change
@@ -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