Compare commits

..
Author SHA1 Message Date
Jeremy Daer 5b28b3106a Build only into once-campfire-edge; release-images promotes releases
This repository's token now writes only the edge package. basecamp/release-images copies an approved edge digest into the release package, so the release environment and the publish token are no longer used here.
2026-10-07 22:55:31 -07:00
Jeremy Daer 9ea475a386 Publish main builds to once-campfire-edge and releases from the release environment 2026-10-07 18:31:03 -07:00
GPT on behalf of DHH 008ea1ab2a Update verified benchmarks for final upstream Rails revision 2026-10-08 00:09:25 +02:00
GPT on behalf of DHH 8de3e22ab4 Clarify database task repair hook scope 2026-10-07 23:51:12 +02:00
GPT on behalf of DHH f792e16e64 Repair message counters within database task pools 2026-10-07 23:50:37 +02:00
GPT on behalf of DHH fd49099729 Merge SQLite-maintained bot pagination counts from PR #337 2026-10-07 23:28:53 +02:00
GPT on behalf of DHH d9c3207886 Merge upstream banned-user push notification fix 2026-10-07 23:19:33 +02:00
GPT on behalf of DHH cd0414ef8f Publish verified architecture-transfer benchmarks 2026-10-07 23:15:30 +02:00
Cursor AgentandThomas Klemm b4ab2df20f Repair messages_count triggers in one SQLite write transaction
ensure! now rechecks trigger presence under BEGIN IMMEDIATE, backfills
drifted rooms.messages_count, and reinstalls all three triggers before
commit so concurrent writers and concurrent boot repairs cannot observe
a partial install or keep a wrong total. install! uses the same
immediate write lock for drop/recreate. Lifecycle regressions cover
missing/partial trigger drift, concurrent ensure!, and writes racing
repair.

Co-authored-by: Thomas Klemm <github@tklemm.eu>
2026-10-07 20:58:32 +00:00
Cursor AgentandThomas Klemm 32748e7fd6 Keep fixtures :all; isolate trigger lifecycle tests
Drop the explicit fixture list and prepend module. Override load_fixtures
only long enough to ensure! + backfill after alphabetical fixture load.
Move destructive trigger DDL into its own test file so parallel CI workers
do not strip triggers from the counter examples.

Co-authored-by: Thomas Klemm <github@tklemm.eu>
2026-10-07 20:14:13 +00:00
GPT on behalf of DHH 7271eb59f4 Wait for persisted messages before leaving system job helpers 2026-10-07 21:56:09 +02:00
Cursor AgentandThomas Klemm 9fd1563c9b Simplify messages_count trigger install and harden ensure!
Require all three SQLite triggers before treating the counter as installed,
drop the schema.rb / dump-rewrite install paths in favor of rake ensure after
schema load, isolate destructive trigger tests, and cover foreign room moves
plus fixture baseline counts.

Co-authored-by: Thomas Klemm <github@tklemm.eu>
2026-10-07 19:46:57 +00:00
GPT on behalf of DHH 105f4b8fd1 Digest session credentials in instrumented fragment keys 2026-10-07 21:45:31 +02:00
GPT on behalf of DHH a95361dd15 Recheck captured snapshot before native fragment lookup 2026-10-07 21:43:26 +02:00
GPT on behalf of DHH 8d02540e31 Bound native fragment caching and collapse concurrent page renders 2026-10-07 21:38:36 +02:00
Cursor AgentandThomas Klemm 1f5858098b Keep bot page totals on SQLite-maintained rooms.messages_count
X-Total-Count on GET /rooms/:id/:bot_key/messages was COUNT(*) of the
room on every page. Serve it from rooms.messages_count updated by SQLite
triggers so Rails, bulk SQL, and foreign writers stay in step — without
ActiveRecord counter_cache callbacks those paths skip.

Fixes #309.

Co-authored-by: Thomas Klemm <github@tklemm.eu>
2026-10-07 19:36:01 +00:00
GPT on behalf of DHH b220486c16 Admit paginated HTML and hydrate CSRF tokens outside presentation keys 2026-10-07 20:58:54 +02:00
GPT on behalf of DHH ac73267b07 Reuse authorized read pages without stale presentation or CSRF masks
Transfer the C completed-response cache lesson into Rails, keeping authentication, room checks and cookies per request. A persistent read-only SQLite observer detects local and foreign commits and rejects racing admission. Whole-page misses render fresh to avoid stale nested fragments; message ETags reflect token-neutral presentation.
2026-10-07 20:02:20 +02:00
59 changed files with 1369 additions and 1056 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ on:
push:
branches: [ main ]
pull_request:
branches: [ main, "release-*" ]
branches: [ main ]
permissions: {}
+5 -13
View File
@@ -41,7 +41,8 @@ jobs:
arch: arm64
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
# Everything goes to <repo>-edge, with this repository's own token.
IMAGE_NAME: ${{ github.repository }}-edge
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -87,9 +88,6 @@ jobs:
type=ref,event=branch
type=ref,event=tag
type=sha,format=short,prefix=sha-
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
flavor: |
latest=false
suffix=-${{ matrix.arch }}
@@ -128,6 +126,8 @@ jobs:
name: Create multi-arch manifest and sign
needs: build
if: github.event_name != 'pull_request'
# Multi-platform tags, signed, in <repo>-edge only. basecamp/release-images
# promotes v* tags from there into the release package after a reviewer approves.
runs-on: ubuntu-latest
permissions:
contents: read
@@ -136,7 +136,7 @@ jobs:
timeout-minutes: 20
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
IMAGE_NAME: ${{ github.repository }}-edge
steps:
- name: Set up Docker Buildx (for imagetools)
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
@@ -166,10 +166,6 @@ jobs:
type=ref,event=branch
type=ref,event=tag
type=sha,format=short,prefix=sha-
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
flavor: |
latest=false
labels: |
@@ -188,10 +184,6 @@ jobs:
[ -z "$tag" ] && continue
echo "Creating manifest for $tag"
src_tag="$tag"
if [[ "$tag" == *:latest && "${GITHUB_REF}" == refs/tags/* ]]; then
ref="${GITHUB_REF#refs/tags/}"
src_tag="${tag%:latest}:$ref"
fi
if [ -n "${IMAGE_DESCRIPTION:-}" ]; then
docker buildx imagetools create \
--tag "$tag" \
+1 -1
View File
@@ -146,7 +146,7 @@ GEM
bcrypt (3.1.22)
benchmark (0.5.0)
bigdecimal (4.1.3)
brakeman (8.1.0)
brakeman (8.0.6)
racc
builder (3.3.0)
bundler-audit (0.9.3)
+11 -5
View File
@@ -49,17 +49,23 @@ If you'd rather run the Docker image yourself, you can read more about that in t
> that people have someone to contact if they need help with their account. If that bothers you, put in any
> email address you want and create yourself a new admin account.
Authenticated room, message, sidebar and search pages use a bounded 64 MiB cache per worker.
Set `CAMPFIRE_RESPONSE_CACHE_MB=0` to disable it. Every request still checks authentication
and room access; commits from any SQLite writer invalidate pages, and CSRF masks stay fresh.
Native HTML, JSON and stream fragments have a separate 64 MiB memory limit per worker;
shared rate limits retain their existing store.
## Other implementations
Campfire also has implementations in Django, Laravel, Express, Elixir, Go, Rust and C:
| HTTP workload (requests/sec) | Rails | [Django](https://github.com/basecamp/once-campfire-django) | [Laravel](https://github.com/basecamp/once-campfire-laravel) | [Express](https://github.com/basecamp/once-campfire-express) | [Elixir](https://github.com/basecamp/once-campfire-elixir) | [Go](https://github.com/basecamp/once-campfire-go) | [Rust](https://github.com/basecamp/once-campfire-rust) | [C](https://github.com/basecamp/once-campfire-c) |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| Room page | 230 | 62 | 760 | 2,622 | 942 | 31,673 | 35,484 | 141,834 |
| Messages page | 402 | 70 | 924 | 3,245 | 1,267 | 30,746 | 40,674 | 151,564 |
| Sidebar | 468 | 229 | 1,383 | 34,938 | 2,515 | 18,586 | 34,479 | 159,850 |
| Search | 399 | 118 | 1,135 | 6,613 | 1,814 | 29,765 | 34,432 | 155,456 |
| Post a message | 248 | 112 | 498 | 2,088 | 1,400 | 9,073 | 8,998 | 7,460 |
| Room page | 710 | 414 | 1,696 | 42,481 | 1,126 | 52,512 | 105,909 | 137,505 |
| Messages page | 1,113 | 454 | 1,890 | 74,779 | 1,407 | 54,100 | 103,301 | 142,669 |
| Sidebar | 1,901 | 576 | 3,364 | 94,460 | 3,621 | 58,714 | 120,930 | 151,001 |
| Search | 1,332 | 549 | 2,615 | 83,493 | 2,127 | 60,444 | 121,502 | 148,766 |
| Post a message | 226 | 113 | 567 | 2,121 | 1,392 | 9,000 | 8,004 | 7,486 |
[Shared verification](https://github.com/basecamp/once-campfire-verification) · [Detailed results](https://github.com/basecamp/once-campfire-verification/blob/main/docs/performance-review.md).
+1 -1
View File
@@ -1,4 +1,4 @@
class ApplicationController < ActionController::Base
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders, InlineScriptHashes
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders, CachedResponses
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
end
@@ -0,0 +1,114 @@
module CachedResponses
extend ActiveSupport::Concern
CACHE_HEADERS = %w[ content-type cache-control etag last-modified vary ].freeze
CSRF_TAG = /<meta\b[^>]*\bname="csrf-token"[^>]*>|<input\b[^>]*\bname="authenticity_token"[^>]*>/
included do
prepend_before_action :capture_response_cache_version
end
def perform_caching
return false unless super && @response_cache_version.present? && !ActiveRecord::Base.connection.transaction_open?
# Check again after authentication, before the first native fragment lookup.
# Later renders retain this immutable namespace and cannot poison a new epoch.
unless defined?(@fragment_cache_snapshot_valid)
@fragment_cache_snapshot_valid = @response_cache_version == ResponseCache.instance.version
end
@fragment_cache_snapshot_valid
end
# Keep the class store (including shared rate limits) and Rails.cache unchanged.
def cache_store
FragmentCache.store
end
def combined_fragment_cache_key(key)
@fragment_cache_namespace ||= [
@response_cache_version, request.base_url, request.script_name, request.format.to_s, I18n.locale,
Current.user&.id, (Digest::SHA256.hexdigest(Current.session.token) if Current.session),
(Digest::SHA256.hexdigest(real_csrf_token) if request.format.html? || request.format.turbo_stream?)
].freeze
super([ @fragment_cache_namespace, key ])
end
private
def capture_response_cache_version
# Capture for native HTML/JSON/stream renders too, even with page reuse off.
# Detached renderers do not run callbacks and therefore render uncached.
@response_cache_version = ResponseCache.instance.version
end
# Register after room authorization, but before presentation queries.
def cache_read_response
if cacheable_read_request?
token = form_authenticity_token
key = response_cache_key
original_session = session.to_hash.deep_dup
return yield if key.bytesize > ResponseCache::MAX_KEY_BYTES
entry = ResponseCache.instance.read(key, @response_cache_version)
rendered = false
unless entry
ResponseCache.instance.synchronize_render(key, @response_cache_version) do
entry = ResponseCache.instance.read(key, @response_cache_version)
if !entry && ResponseCache.instance.version == @response_cache_version
yield
rendered = true
cache_completed_response(key, original_session)
end
end
end
if entry
response.headers.merge!(entry[:headers])
self.response_body = entry[:body].gsub(entry[:marker], token)
elsif !rendered
# A queued request retains its pre-auth snapshot. If it has expired,
# render outside the stripe instead of blocking the next generation.
yield
end
else
yield
end
end
def cache_completed_response(key, original_session)
if response.status == 200 && response.media_type == "text/html" && session.to_hash == original_session
marker = "campfire-csrf-#{SecureRandom.hex(32)}"
# Replace only framework token attributes, never a matching token in
# message text. Postprocessing also leaves fragment caches untouched.
body = csrf_neutral_body(response.body, marker)
headers = response.headers.slice(*CACHE_HEADERS).to_h.freeze
ResponseCache.instance.write(key, @response_cache_version, { body: body.freeze, marker: marker.freeze, headers: headers }.freeze)
end
end
def cacheable_read_request?
ResponseCache.instance.budget.positive? && @response_cache_version && request.get? && request.format.html? && Current.session &&
!authenticated_by.bot_key? && flash.empty? &&
!request.headers["If-None-Match"] && !request.headers["If-Modified-Since"] &&
!Rails.application.config.content_security_policy_nonce_generator &&
!ActiveRecord::Base.connection.transaction_open?
end
def response_cache_key
ActiveSupport::JSON.encode([
controller_path, request.fullpath, request.base_url, request.user_agent,
request.headers["Accept"], request.headers["Turbo-Frame"], I18n.locale,
# Tokens are hydrated per request, including clients that replay an old
# cookie. Their raw CSRF secret does not select a presentation variant.
Current.user.id, Current.session.token, session.to_hash.except("_csrf_token"),
cookies.to_h.except("_campfire_session", "session_token")
])
end
def csrf_neutral_body(body, marker)
body.gsub(CSRF_TAG) do |tag|
attribute = tag.start_with?("<meta") ? "content" : "value"
tag.sub(/\b#{attribute}="[^"]*"/, %(#{attribute}="#{marker}"))
end
end
end
@@ -1,50 +0,0 @@
# Allows the scripts the importmap helper emits and nothing else: the inline
# importmap and its entry-point module by content hash, and the asset
# directory its URLs point into. Not the whole origin, because uploads are
# served from this origin too. The hashes change only when the importmap does
# (a deploy), so cached HTML stays valid, unlike a per-request nonce.
module InlineScriptHashes
extend ActiveSupport::Concern
ENTRY_POINT_SCRIPT = %(import "application")
included do
# With the policy turned off there's nothing to add to, so leave the
# request without one rather than starting an empty policy
content_security_policy if: -> { request.content_security_policy } do |policy|
if sources = policy.directives["script-src"]
importmap = Rails.application.importmap.to_json(resolver: helpers)
policy.directives["script-src"] = (sources - [ "'self'" ]) | script_locations(importmap) | script_hashes(importmap, ENTRY_POINT_SCRIPT)
policy.worker_src "#{request.host_with_port}#{service_worker_path(format: :js)}"
end
end
end
private
# The importmap is resolved by Rails' asset helpers, so its URLs already
# carry any relative root and asset host, however the host is written:
# with or without a scheme, protocol-relative, a %d set, or a proc. Each
# is allowed up to its asset directory, the last one in its path, since an
# asset host's own path may contain one too; a URL outside it is allowed
# exactly.
#
# This origin's own sources leave out the scheme, so they match the page's
# scheme even behind a proxy that terminates TLS without saying so.
#
# A path only limits the first request: browsers check just the origin
# after a redirect. Nothing under the asset directory redirects.
def script_locations(importmap)
assets = File.join(config.relative_url_root.to_s, Rails.application.config.assets.prefix, "")
JSON.parse(importmap)["imports"].values.map do |url|
url = URI.join(request.base_url, url)
origin = url.origin == request.base_url ? request.host_with_port : url.origin
directory = url.path[0, url.path.rindex(assets) + assets.length] if url.path.include?(assets)
"#{origin}#{directory || url.path}"
end.uniq
end
def script_hashes(*scripts)
scripts.map { |script| "'sha256-#{Digest::SHA256.base64digest(script)}'" }
end
end
@@ -37,7 +37,7 @@ class Messages::ByBotsController < MessagesController
end
def set_pagination_headers
headers["X-Total-Count"] = @room.messages.count.to_s
headers["X-Total-Count"] = @room.messages_count.to_s
if next_page = next_page_params
headers["Link"] = %(<#{room_bot_messages_url(@room, params[:bot_key], **next_page)}>; rel="next")
+9 -1
View File
@@ -4,6 +4,7 @@ class MessagesController < ApplicationController
before_action :set_room, except: :create
before_action :set_message, only: %i[ show edit update destroy ]
before_action :ensure_can_administer, only: %i[ edit update destroy ]
around_action :cache_read_response, only: :index
layout false, only: :index
@@ -11,7 +12,14 @@ class MessagesController < ApplicationController
@messages = find_paged_messages
if @messages.any?
fresh_when @messages
body = render_to_string(:index)
# Creator, body and boost edits can change HTML without touching messages.
# Masked CSRF tokens remain fresh while the presentation validator stays stable.
fresh_when etag: Digest::SHA256.hexdigest(csrf_neutral_body(body, "")), template: false
unless performed?
response.content_type = "text/html"
self.response_body = body
end
else
head :no_content
end
+1
View File
@@ -2,6 +2,7 @@ class RoomsController < ApplicationController
before_action :set_room, only: %i[ show destroy ]
before_action :ensure_can_administer, only: %i[ destroy ]
before_action :remember_last_room_visited, only: :show
around_action :cache_read_response, only: :show
def index
redirect_to room_url(Current.user.rooms.last)
+1
View File
@@ -1,4 +1,5 @@
class SearchesController < ApplicationController
around_action :cache_read_response, only: :index
before_action :set_messages
def index
+3 -33
View File
@@ -1,23 +1,14 @@
class Users::ProfilesController < ApplicationController
before_action :set_user
# Limited like signing in, so the current password can't be guessed here instead.
rate_limit to: 10, within: 3.minutes, only: :update, if: :changing_password?,
with: -> { redirect_to user_profile_url, alert: "Too many attempts. Try again in a few minutes." }
def show
@direct_memberships, @shared_memberships =
Current.user.memberships.with_ordered_room.partition { |m| m.room.direct? }
end
def update
if @user.update user_params
start_new_session_for @user if @user.saved_change_to_password_digest?
redirect_to user_profile_url, notice: update_notice
else
redirect_to user_profile_url, alert: update_alert
end
@user.update user_params
redirect_to user_profile_url, notice: update_notice
end
private
@@ -26,31 +17,10 @@ class Users::ProfilesController < ApplicationController
end
def user_params
permitted = params.require(:user).permit(:name, :avatar, :email_address, :password, :bio).compact
if changing_password?
permitted.merge(password_challenge: params[:user][:password_challenge].to_s)
else
permitted
end
end
# As has_secure_password reads it: an empty password leaves the current one alone.
def changing_password?
!params.dig(:user, :password).to_s.empty?
params.require(:user).permit(:name, :avatar, :email_address, :password, :bio).compact
end
def update_notice
params[:user][:avatar] ? "It may take up to 30 minutes to change everywhere." : "✓"
end
def update_alert
if !@user.errors.include?(:password_challenge)
@user.errors.full_messages.to_sentence
elsif params[:user][:password_challenge].blank?
"Enter your current password to change it."
else
"That isn't your current password."
end
end
end
@@ -5,14 +5,17 @@ class Users::PushSubscriptionsController < ApplicationController
end
def create
subscription = @push_subscriptions.find_or_initialize_by(push_subscription_params)
subscription.user_agent = request.user_agent if subscription.new_record?
# Existing endpoints must pass current validations too
if subscription.valid?
head register_while_signed_in(subscription) ? :ok : :unauthorized
if subscription = @push_subscriptions.find_by(push_subscription_params)
# Existing endpoints must pass current validations
if subscription.valid?
subscription.touch
head :ok
else
head :unprocessable_entity
end
else
head :unprocessable_entity
subscription = @push_subscriptions.create push_subscription_params.merge(user_agent: request.user_agent)
head subscription.persisted? ? :ok : :unprocessable_entity
end
end
@@ -26,24 +29,6 @@ class Users::PushSubscriptionsController < ApplicationController
@push_subscriptions = Current.user.push_subscriptions
end
# A password change ends every session and subscription. A request that authenticated just before it mustn't
# put one back, so the session is checked under the same write lock as the save, and the endpoint is looked up
# again there, past the query cache, so two tabs posting at once register it once. Validation, which resolves
# the endpoint's address, runs before taking the lock.
def register_while_signed_in(subscription)
Push::Subscription.uncached do
Push::Subscription.transaction do
if Session.exists?(Current.session.id)
if existing = @push_subscriptions.find_by(push_subscription_params)
existing.touch
else
subscription.save!(validate: false)
end
end
end
end
end
def push_subscription_params
params.require(:push_subscription).permit(:endpoint, :p256dh_key, :auth_key)
end
@@ -1,5 +1,6 @@
class Users::SidebarsController < ApplicationController
DIRECT_PLACEHOLDERS = 20
around_action :cache_read_response, only: :show
def show
visible_memberships = Current.user.memberships.visible
+1 -1
View File
@@ -5,7 +5,7 @@ module ContentFilters
#
# * SanitizeTags strips disallowed markup from the message body
# * Action Text sanitizes the rendered content (lib/rails_ext/action_text_allowed_tags.rb)
# * the final html is re-sanitized before its text is linked (MessagesHelper#auto_link_text)
# * auto_link re-sanitizes the final html (MessagesHelper#message_presentation)
EDITOR_FORMATTING_TAGS = %w[ s u mark table thead tbody tfoot tr th td ]
EDITOR_FORMATTING_ATTRIBUTES = %w[ data-language ]
+4 -24
View File
@@ -1,12 +1,9 @@
module MessagesHelper
# Rendered text is re-sanitized before linking. Rails' default safe list
# lacks the formatting the rich text editor produces
# auto_link re-sanitizes with Rails' default safe list, which lacks the
# formatting the rich text editor produces
AUTO_LINK_ALLOWED_TAGS = Rails::HTML5::SafeListSanitizer.allowed_tags + ContentFilters::EDITOR_FORMATTING_TAGS
AUTO_LINK_ALLOWED_ATTRIBUTES = Rails::HTML5::SafeListSanitizer.allowed_attributes + ContentFilters::EDITOR_FORMATTING_ATTRIBUTES
# Text auto_link could turn into a link: a URL has a scheme or www., an email address an @
LINKABLE_TEXT = /:|@|www\./i
def message_area_tag(room, &)
tag.div id: "message-area", class: "message-area", contents: true, data: {
controller: "messages presence drop-target",
@@ -66,7 +63,8 @@ module MessagesHelper
when "sound"
message_sound_presentation(message)
else
auto_link_text h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body))
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)),
html: { target: "_blank" }, sanitize_options: { tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES }
end
rescue Exception => e
Sentry.capture_exception(e, extra: { message: message })
@@ -76,24 +74,6 @@ module MessagesHelper
end
private
# auto_link finds URLs and email addresses with regular expressions, which
# can't tell an attribute value from text. Hand it one text node at a time,
# escaped as the serializer writes text, so a link can only land between tags.
def auto_link_text(html)
fragment = Nokogiri::HTML5.fragment(sanitize(html, tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES))
fragment.xpath(".//text()[not(ancestor::a)]").each do |node|
if node.text.match?(LINKABLE_TEXT)
text = node.to_html
linked = auto_link(text, html: { target: "_blank" })
node.replace Nokogiri::HTML5.fragment(linked) unless linked == text
end
end
# A <pre> starting with a newline needs an extra one written out to survive the next parse
fragment.to_html(preserve_newline: true).html_safe
end
def messages_actions
"turbo:before-stream-render@document->messages#beforeStreamRender keydown.up@document->messages#editMyLastMessage"
end
-1
View File
@@ -2,7 +2,6 @@ module TranslationsHelper
TRANSLATIONS = {
email_address: { "🇺🇸": "Enter your email address", "🇪🇸": "Introduce tu correo electrónico", "🇫🇷": "Entrez votre adresse courriel", "🇮🇳": "अपना ईमेल पता दर्ज करें", "🇩🇪": "Geben Sie Ihre E-Mail-Adresse ein", "🇧🇷": "Insira seu endereço de email", "🇯🇵": "メールアドレスを入力してください" },
password: { "🇺🇸": "Enter your password", "🇪🇸": "Introduce tu contraseña", "🇫🇷": "Saisissez votre mot de passe", "🇮🇳": "अपना पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr Passwort ein", "🇧🇷": "Insira sua senha", "🇯🇵": "パスワードを入力してください" },
current_password: { "🇺🇸": "Enter your current password", "🇪🇸": "Introduce tu contraseña actual", "🇫🇷": "Saisissez votre mot de passe actuel", "🇮🇳": "अपना वर्तमान पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr aktuelles Passwort ein", "🇧🇷": "Insira sua senha atual", "🇯🇵": "現在のパスワードを入力してください" },
update_password: { "🇺🇸": "Change password", "🇪🇸": "Cambiar contraseña", "🇫🇷": "Changer le mot de passe", "🇮🇳": "पासवर्ड बदलें", "🇩🇪": "Passwort ändern", "🇧🇷": "Alterar senha", "🇯🇵": "パスワードを変更" },
user_name: { "🇺🇸": "Enter your name", "🇪🇸": "Introduce tu nombre", "🇫🇷": "Entrez votre nom", "🇮🇳": "अपना नाम दर्ज करें", "🇩🇪": "Geben Sie Ihren Namen ein", "🇧🇷": "Insira seu nome", "🇯🇵": "お名前を入力してください" },
account_name: { "🇺🇸": "Name this account", "🇪🇸": "Nombre de esta cuenta", "🇫🇷": "Nommez ce compte", "🇮🇳": "इस खाते का नाम दें", "🇩🇪": "Benennen Sie dieses Konto", "🇧🇷": "Dê um nome a essa conta", "🇯🇵": "アカウントに名前を付ける" },
@@ -1,7 +0,0 @@
module Users::PushSubscriptionsHelper
# Names the session without revealing its token, so the browser can tell when it's in a new one. The creation
# time keeps an id reused by a new database from looking like the same session.
def push_subscription_session_marker
Digest::SHA256.hexdigest("push-subscription-session-#{Current.session.id}-#{Current.session.created_at.to_f}") if Current.session
end
end
@@ -106,8 +106,7 @@ export default class extends Controller {
async #syncPushSubscription(subscription) {
const response = await post(this.subscriptionsUrlValue, { body: this.#extractJsonPayloadAsString(subscription), responseKind: "turbo-stream" })
// Only a refused endpoint is for good. A session signed out mid-request posts it again after signing in.
if (response.unprocessableEntity) subscription.unsubscribe()
if (!response.ok) subscription.unsubscribe()
}
async #requestPermissionAndSubscribe(registration) {
@@ -1,32 +0,0 @@
import { Controller } from "@hotwired/stimulus"
import { post } from "@rails/request.js"
const SYNCED_SESSION_KEY = "push-subscription-synced-session"
// A password change removes the account's push subscriptions, the one this browser holds included. Post it again
// once per session, so a browser that's still signed in keeps getting notifications. A failure here leaves the
// browser's own subscription alone: the next session tries again.
export default class extends Controller {
static values = { url: String, session: String }
async connect() {
if (this.#allowed && this.sessionValue && localStorage.getItem(SYNCED_SESSION_KEY) !== this.sessionValue) {
const registration = await navigator.serviceWorker.getRegistration(window.location.origin)
const subscription = await registration?.pushManager?.getSubscription()
if (Notification.permission === "granted" && subscription) {
await post(this.urlValue, { body: this.#payload(subscription), responseKind: "turbo-stream" })
localStorage.setItem(SYNCED_SESSION_KEY, this.sessionValue)
}
}
}
get #allowed() {
return navigator.serviceWorker && window.Notification
}
#payload(subscription) {
const { endpoint, keys: { p256dh, auth } } = subscription.toJSON()
return JSON.stringify({ push_subscription: { endpoint, p256dh_key: p256dh, auth_key: auth } })
}
}
@@ -28,7 +28,7 @@ export default class extends Controller {
}
#stripMentionAttachments(node) {
node.querySelectorAll(".mention").forEach(mention => mention.replaceWith(mention.textContent.trim()))
node.querySelectorAll(".mention").forEach(mention => mention.outerHTML = mention.textContent.trim())
return node
}
+9
View File
@@ -0,0 +1,9 @@
# Native view caches share one byte budget across every database generation.
class FragmentCache
STORE = ActiveSupport::Cache::MemoryStore.new(size: 64.megabytes)
private_constant :STORE
def self.store
STORE
end
end
+1 -4
View File
@@ -23,14 +23,11 @@ class Opengraph::Fetch
end
private
# The nil proxy address ignores http_proxy: a proxy would look the host up again, and the connection
# would no longer go to the checked IP.
#
# The timeouts bound each operation. A host can still send its headers or body a byte at a time,
# in as many reads as it likes: UnfurlLinksController puts the whole unfurl under one deadline.
def request(url, request_class, ip:)
MAX_REDIRECTS.times do
Net::HTTP.start(url.host, url.port, nil, ipaddr: ip, use_ssl: url.scheme == "https", **timeouts) do |http|
Net::HTTP.start(url.host, url.port, ipaddr: ip, use_ssl: url.scheme == "https", **timeouts) do |http|
http.request request_class.new(url) do |response|
if response.is_a?(Net::HTTPRedirection)
url, ip = resolve_redirect(response["location"])
+98
View File
@@ -0,0 +1,98 @@
require "sqlite3"
# A read-only observer sees commits from every writer, including other runtimes.
# PRAGMA data_version can only be compared on the same persistent connection.
class ResponseCache
MAX_ENTRY_BYTES = 1.megabyte
MAX_KEY_BYTES = 2.kilobytes
def self.instance
INSTANCE
end
def initialize
@mutex = Mutex.new
@entries = {}
@bytes = 0
@render_locks = Array.new(16) { Mutex.new }
end
def budget
[ ENV.fetch("CAMPFIRE_RESPONSE_CACHE_MB", "64").to_i, 0 ].max.megabytes
end
def version
@mutex.synchronize { current_version }
rescue SQLite3::Exception
clear
nil
end
def read(key, version)
@mutex.synchronize do
@entries[key]&.first if current_version == version
end
rescue SQLite3::Exception
clear
nil
end
# Collapse cold renders without retaining a mutex for every viewer or URL.
# Rendering must never hold the observer/entry mutex: controllers read it too.
def synchronize_render(key, version, &block)
@render_locks[[ key, version ].hash % @render_locks.length].synchronize(&block)
end
def write(key, version, entry)
size = key.bytesize + entry[:body].bytesize + entry[:headers].sum { |name, value| name.bytesize + value.bytesize } + 256
return if key.bytesize > MAX_KEY_BYTES || size > [ budget, MAX_ENTRY_BYTES ].min
@mutex.synchronize do
return unless current_version == version
return if @entries.key?(key)
while @entries.any? && @bytes + size > budget
_, (_, removed_size) = @entries.shift
@bytes -= removed_size
end
@entries[key] = [ entry, size ]
@bytes += size
end
rescue SQLite3::Exception
clear
end
def clear
@mutex.synchronize do
@entries.clear
@bytes = 0
@observer&.close
@observer = @database = @version = nil
end
end
private
def current_version
database = File.expand_path(ActiveRecord::Base.connection_db_config.database)
if @database != database || !@observer
@entries.clear
@bytes = 0
@observer&.close
@observer = nil
@observer = SQLite3::Database.new(database, readonly: true)
@database = database
@namespace = SecureRandom.hex(16)
@version = nil
end
version = @observer.get_first_value("PRAGMA data_version")
if @version != version
@entries.clear
@bytes = 0
@version = version
end
[ @database, @namespace, @version ]
end
INSTANCE = new
end
+99
View File
@@ -0,0 +1,99 @@
# Keeps rooms.messages_count correct for ordinary SQLite writes — Rails, bulk SQL,
# and foreign connections — without ActiveRecord counter_cache callbacks that
# those paths skip (and that would double-count if combined with triggers).
# Foreign REPLACE writes require PRAGMA recursive_triggers=ON so SQLite also
# fires the delete trigger. Migrations rebuilding messages must call ensure!
# before reading counters inside that migration; database tasks repair afterwards.
class Room::MessagesCount
INSERT_TRIGGER = "messages_ai_rooms_messages_count"
DELETE_TRIGGER = "messages_ad_rooms_messages_count"
UPDATE_TRIGGER = "messages_au_rooms_messages_count"
TRIGGERS = [ INSERT_TRIGGER, DELETE_TRIGGER, UPDATE_TRIGGER ].freeze
class << self
def install!(connection = ActiveRecord::Base.connection)
with_immediate_write(connection) do
replace_triggers!(connection)
end
end
def uninstall!(connection = ActiveRecord::Base.connection)
TRIGGERS.each do |name|
connection.execute("DROP TRIGGER IF EXISTS #{name}")
end
end
def backfill!(connection = ActiveRecord::Base.connection)
connection.execute <<~SQL
UPDATE rooms SET messages_count = (
SELECT COUNT(*) FROM messages WHERE messages.room_id = rooms.id
)
SQL
end
# schema.rb does not dump SQLite triggers; reinstall after schema:load.
# Repair rechecks, rebuilds counts, and installs triggers in one write txn
# so concurrent writers and concurrent boot repairs cannot observe a gap.
def ensure!(connection = ActiveRecord::Base.connection)
return unless connection.adapter_name.match?(/sqlite/i)
return unless connection.data_source_exists?(:rooms)
return unless connection.column_exists?(:rooms, :messages_count)
return if triggers_installed?(connection)
with_immediate_write(connection) do
next if triggers_installed?(connection)
uninstall!(connection)
backfill!(connection)
create_triggers!(connection)
end
end
def trigger_installed?(connection, name)
connection.select_value(
"SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = #{connection.quote(name)}"
).present?
end
def triggers_installed?(connection = ActiveRecord::Base.connection)
TRIGGERS.all? { |name| trigger_installed?(connection, name) }
end
private
def with_immediate_write(connection)
if connection.transaction_open?
yield
else
connection.raw_connection.transaction(:immediate) { yield }
end
end
def replace_triggers!(connection)
uninstall!(connection)
create_triggers!(connection)
end
def create_triggers!(connection)
connection.execute <<~SQL
CREATE TRIGGER #{INSERT_TRIGGER} AFTER INSERT ON messages
BEGIN
UPDATE rooms SET messages_count = messages_count + 1 WHERE id = NEW.room_id;
END
SQL
connection.execute <<~SQL
CREATE TRIGGER #{DELETE_TRIGGER} AFTER DELETE ON messages
BEGIN
UPDATE rooms SET messages_count = messages_count - 1 WHERE id = OLD.room_id;
END
SQL
connection.execute <<~SQL
CREATE TRIGGER #{UPDATE_TRIGGER} AFTER UPDATE OF room_id ON messages
WHEN OLD.room_id IS NOT NEW.room_id
BEGIN
UPDATE rooms SET messages_count = messages_count - 1 WHERE id = OLD.room_id;
UPDATE rooms SET messages_count = messages_count + 1 WHERE id = NEW.room_id;
END
SQL
end
end
end
-26
View File
@@ -18,10 +18,8 @@ class User < ApplicationRecord
enum :status, %i[ active deactivated banned ], default: :active
has_secure_password validations: false
validate :password_challenge_matches, unless: -> { password_challenge.nil? }
after_create_commit :grant_membership_to_open_rooms
after_update :sign_out_all_sessions, if: :saved_change_to_password_digest?
scope :ordered, -> { order("LOWER(name)") }
scope :filtered_by, ->(query) { where("name like ?", "%#{query}%") }
@@ -52,30 +50,6 @@ class User < ApplicationRecord
end
private
# The profile sets password_challenge whenever it changes the password; scripts that reset one don't.
def password_challenge_matches
unless password_digest_was.present? && BCrypt::Password.new(password_digest_was).is_password?(password_challenge)
errors.add :password_challenge, :invalid
end
end
# A new password is how someone shuts out whoever else got into their account, so it ends every session, copies
# of the changing browser's cookie included, and every push subscription; browsers still signed in post theirs
# again. Connections close once that commits, registered on the transaction because a later save in it would
# replace the saved change an after_commit condition reads.
def sign_out_all_sessions
sessions.delete_all
push_subscriptions.delete_all
self.class.current_transaction.after_commit { disconnect_signed_out_sessions }
end
# The password is saved by now, so a realtime service that can't be reached mustn't fail the change.
def disconnect_signed_out_sessions
reset_remote_connections
rescue => error
Rails.logger.warn "Could not disconnect remote connections after a password change: #{error.class}"
end
# One statement, which SQLite runs under the write lock, so no room is closed between reading the open ones and
# inserting. A room that Room#destroy_later closes is either closed before, and skipped, or after, and takes this
# membership away with the others. A room that granted itself to every user when it was created is skipped too,
+2 -9
View File
@@ -3,20 +3,13 @@ module User::Transferable
TRANSFER_LINK_EXPIRY_DURATION = 4.hours
included do
# Bound to the password, so changing it also ends sign-in links made before.
generates_token_for :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION do
password_salt&.last(10)
end
end
class_methods do
def find_by_transfer_id(id)
find_by_token_for(:transfer, id)
find_signed(id, purpose: :transfer)
end
end
def transfer_id
generate_token_for(:transfer)
signed_id(purpose: :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION)
end
end
-5
View File
@@ -63,11 +63,6 @@
<%= render "layouts/lightbox" %>
<% if Current.session %>
<%= tag.div hidden: true, data: { controller: "push-subscription-sync",
push_subscription_sync_url_value: user_push_subscriptions_path, push_subscription_sync_session_value: push_subscription_session_marker } %>
<% end %>
<a href="https://once.com" id="app-logo" target="_blank" aria-label="Once software from 37signals home page">
<%= image_tag "campfire-icon.png", alt: "Campfire logo", width: 256, height: 216 %>
</a>
+1 -1
View File
@@ -1,7 +1,7 @@
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
<%# Bump this version when the message presentation filters change what they emit. Editing this line changes the template digest, which busts BOTH this fragment cache and the collection cache that keys on this partial's digest (helper Ruby changes alone don't). %>
<% cache [ message, "presentation-v7" ] do %>
<% cache [ message, "presentation-v6" ] do %>
<%= message_tag message do %>
<h2 class="message__day-separator"><%= local_datetime_tag message.created_at, style: :date %></h2>
-10
View File
@@ -69,16 +69,6 @@
</label>
</div>
<div class="flex align-center gap">
<%= translation_button(:current_password) %>
<label class="flex align-center gap flex-item-grow input input--actor">
<%= form.password_field :password_challenge, class: "input txt-large", autocomplete: "current-password",
placeholder: "Current password", required: false, maxlength: 72 %>
<%= image_tag "password.svg", aria: { hidden: "true" }, size: 24, class: "colorize--black" %>
</label>
</div>
<div class="flex align-center gap">
<%= translation_button(:update_password) %>
+23 -19
View File
@@ -1,21 +1,25 @@
# Enforcing baseline policy. Application pages narrow script-src from this
# origin to its asset directory, since uploads are served from this origin
# too, and allow the two inline scripts the importmap helper emits by hash
# (InlineScriptHashes). There is no per-request nonce, so rendered HTML stays
# cacheable. 'self' remains for responses outside ApplicationController, none
# of which render user content.
# Be sure to restart your server when you modify this file.
# Define an application-wide content security policy.
# See the Securing Rails Applications Guide for more information:
# https://guides.rubyonrails.org/security.html#content-security-policy-header
# Rails.application.configure do
# config.content_security_policy do |policy|
# policy.default_src :self, :https
# policy.font_src :self, :https, :data
# policy.img_src :self, :https, :data
# policy.object_src :none
# policy.script_src :self, :https
# policy.style_src :self, :https
# # Specify URI for violation reports
# # policy.report_uri "/csp-violation-report-endpoint"
# end
#
# Nothing else is restricted: images, media, fonts, styles (including an
# account's custom styles) and connections behave as before.
# # Generate session nonces for permitted importmap and inline scripts
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
# config.content_security_policy_nonce_directives = %w(script-src)
#
# DISABLE_CONTENT_SECURITY_POLICY=true is the escape hatch for an install whose
# proxy or customization injects scripts the policy would block.
unless ENV["DISABLE_CONTENT_SECURITY_POLICY"] == "true"
Rails.application.configure do
config.content_security_policy do |policy|
policy.script_src :self
policy.object_src :none
policy.base_uri :self
end
end
end
# # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true
# end
+24
View File
@@ -0,0 +1,24 @@
require "jbuilder/jbuilder_template"
# Jbuilder 2.14.1 uses Rails.cache directly; use the same bounded store as ERB
# while retaining its native keys and controller instrumentation.
module BoundedJbuilderFragments
private
def _read_fragment_cache(key, options = nil)
@context.controller.instrument_fragment_cache :read_fragment, key do
@context.controller.cache_store.read(key, options)
end
end
def _write_fragment_cache(key, options = nil)
@context.controller.instrument_fragment_cache :write_fragment, key do
yield.tap { |value| @context.controller.cache_store.write(key, value, options) }
end
end
end
JbuilderTemplate.prepend(BoundedJbuilderFragments)
Rails.application.config.to_prepare do
ActionView::PartialRenderer.collection_cache = FragmentCache.store
end
@@ -0,0 +1,5 @@
# schema.rb cannot dump SQLite triggers. Cover existing DBs that lost them.
Rails.application.config.after_initialize do
Room::MessagesCount.ensure!
rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished
end
@@ -1,34 +0,0 @@
# Uploaded files are served from this origin. Application pages only allow
# scripts from the asset directory (InlineScriptHashes), so an upload can't
# load as a script whatever type it's served with. This guard is the second
# layer: browsers accept sixteen spellings of a JavaScript type, and Disk URLs
# sign the type they were minted with, so instead of refusing a list of types,
# anything Active Storage serves that isn't plainly media goes out as a
# download, whatever its URL says. A multi-range response keeps its
# multipart/byteranges type and boundary, which no browser runs as a script.
# Only reads are touched: uploading (direct upload JSON, Disk PUT) answers in
# its own types.
#
# A cache that stored an upload under a script type before this shipped can
# still serve it that way until the entry expires (an hour for Disk URLs, and
# never for blob proxy URLs, which Rails caches as immutable). The policy's
# path restriction is what keeps such a copy from running.
class UploadedScriptGuard
SERVABLE = %r{\A(image|video|audio)/|\A(application/pdf|application/octet-stream|text/plain|multipart/byteranges)\z}
def initialize(app)
@app = app
end
def call(env)
status, headers, body = @app.call(env)
if env["REQUEST_METHOD"].in?(%w[ GET HEAD ]) && env["PATH_INFO"].start_with?("#{ActiveStorage.routes_prefix}/") && (type = headers["content-type"])
headers["content-type"] = ActiveStorage.binary_content_type unless type.split(";").first.strip.downcase.match?(SERVABLE)
end
[ status, headers, body ]
end
end
Rails.application.config.middleware.use UploadedScriptGuard
@@ -0,0 +1,15 @@
class AddMessagesCountToRooms < ActiveRecord::Migration[8.2]
def up
add_column :rooms, :messages_count, :integer, null: false, default: 0
# Backfill before installing triggers so the COUNT rewrite does not race with
# concurrent inserts, and so we never rely on Rails callbacks for the tally.
Room::MessagesCount.backfill!(connection)
Room::MessagesCount.install!(connection)
end
def down
Room::MessagesCount.uninstall!(connection)
remove_column :rooms, :messages_count
end
end
Generated
+3 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2026_10_05_022000) do
ActiveRecord::Schema[8.2].define(version: 2026_10_07_190000) do
create_table "accounts", force: :cascade do |t|
t.datetime "created_at", null: false
t.text "custom_styles"
@@ -124,6 +124,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_10_05_022000) do
t.string "name"
t.string "type", null: false
t.datetime "updated_at", null: false
t.integer "messages_count", default: 0, null: false
end
create_table "searches", force: :cascade do |t|
@@ -183,3 +184,4 @@ ActiveRecord::Schema[8.2].define(version: 2026_10_05_022000) do
# Note that virtual tables may not work with other database engines. Be careful if changing database.
create_virtual_table "message_search_index", "fts5", ["body", "tokenize=porter"]
end
+29
View File
@@ -0,0 +1,29 @@
# Repair the database selected by each schema-load or migration operation.
# db:prepare calls DatabaseTasks directly, bypassing Rake enhancements;
# method hooks also run on every invocation.
module RoomMessagesCountDatabaseTasks
def load_schema(...)
super.tap { repair_room_messages_count }
end
def migrate(...)
super.tap { repair_room_messages_count }
end
private
def repair_room_messages_count
migration_connection_pool.with_connection do |connection|
Room::MessagesCount.ensure!(connection)
end
end
end
ActiveRecord::Tasks::DatabaseTasks.singleton_class.prepend(RoomMessagesCountDatabaseTasks)
namespace :room_messages_count do
task ensure: :environment do
ActiveRecord::Base.connection_pool.with_connection do |connection|
Room::MessagesCount.ensure!(connection)
end
end
end
+165
View File
@@ -0,0 +1,165 @@
require "test_helper"
class CachedResponsesTest < ActionDispatch::IntegrationTest
self.use_transactional_tests = false
setup do
host! "once.campfire.test"
sign_in :david
@login_cookie = cookies["_campfire_session"]
@previous_forgery = ActionController::Base.allow_forgery_protection
ActionController::Base.allow_forgery_protection = true
@previous_cache = Rails.cache
@previous_caching = ActionController::Base.perform_caching
Rails.cache = ActiveSupport::Cache::MemoryStore.new
ActionController::Base.perform_caching = true
@room = rooms(:watercooler)
# Establish last-room and CSRF cookies before checking reuse.
2.times { get room_url(@room) }
ResponseCache.instance.clear
end
teardown do
ResponseCache.instance.clear
ActionController::Base.allow_forgery_protection = @previous_forgery
Rails.cache = @previous_cache
ActionController::Base.perform_caching = @previous_caching
end
test "all four read actions reuse completed pages" do
urls = [ room_url(@room), room_messages_url(@room), user_sidebar_url(:me), searches_url(q: "hello") ]
urls.each do |url|
get url
assert_response :success
end
ResponseCache.instance.expects(:write).never
urls.each do |url|
get url
assert_response :success
end
end
test "pagination caches the completed HTML rather than silently bypassing admission" do
get room_messages_url(@room)
assert_response :success
assert_equal "text/html", response.media_type
MessagesController.any_instance.expects(:find_paged_messages).never
get room_messages_url(@room)
assert_response :success
end
test "clients without a persisted CSRF session still reuse token-neutral HTML" do
cookies["_campfire_session"] = @login_cookie
get room_url(@room)
first = css_select('meta[name="csrf-token"]').first["content"]
cookies["_campfire_session"] = @login_cookie
ResponseCache.instance.expects(:write).never
get room_url(@room)
second = css_select('meta[name="csrf-token"]').first["content"]
assert_response :success
assert_not_equal first, second
assert_no_match /campfire-csrf-/, response.body
post room_messages_url(@room, format: :turbo_stream), params: {
authenticity_token: second, message: { body: "fresh replay token works", client_message_id: "cache-replay-token" } }
assert_response :success
end
test "cached tokens stay fresh and literal token-like text survives" do
get room_url(@room)
literal = css_select('meta[name="csrf-token"]').first["content"]
@room.messages.create!(creator: users(:david), body: "literal #{literal}")
get room_url(@room)
first = css_select('meta[name="csrf-token"]').first["content"]
get room_url(@room)
second = css_select('meta[name="csrf-token"]').first["content"]
assert_not_equal first, second
assert_includes response.body, "literal #{literal}"
assert_no_match /campfire-csrf-/, response.body
post room_messages_url(@room, format: :turbo_stream), params: {
authenticity_token: second, message: { body: "cached token works", client_message_id: "cache-token" } }
assert_response :success
end
test "local and foreign commits invalidate pages and nested fragments" do
message = @room.messages.ordered.last
get room_url(@room)
@room.messages.create!(creator: users(:david), body: "local commit")
get room_url(@room)
assert_includes response.body, "local commit"
foreign_write("UPDATE users SET name = ? WHERE id = ?", "Foreign creator", message.creator_id)
get room_url(@room)
assert_includes response.body, "Foreign creator"
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign message body", message.id)
get room_url(@room)
assert_includes response.body, "foreign message body"
end
test "cached room access and sessions are checked afresh" do
get room_url(@room)
foreign_write("DELETE FROM memberships WHERE room_id = ? AND user_id = ?", @room.id, users(:david).id)
get room_url(@room)
assert_redirected_to root_url
get user_sidebar_url(:me)
foreign_write("DELETE FROM sessions WHERE user_id = ?", users(:david).id)
get user_sidebar_url(:me)
assert_redirected_to new_session_url
end
test "conditional requests keep native validators" do
get room_messages_url(@room)
etag = response.headers["ETag"]
assert etag.present?
get room_messages_url(@room), headers: { "If-None-Match" => etag }
assert_response :not_modified
end
test "foreign presentation changes cannot return a false not-modified response" do
get room_messages_url(@room)
etag = response.headers["ETag"]
message = @room.messages.ordered.last
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "changed presentation", message.id)
get room_messages_url(@room), headers: { "If-None-Match" => etag }
assert_response :success
assert_includes response.body, "changed presentation"
assert_not_equal etag, response.headers["ETag"]
end
test "origins and frame variants have separate entries" do
get room_url(@room)
ResponseCache.instance.expects(:write).twice
get room_url(@room), headers: { "Turbo-Frame" => "different-frame" }
host! "once.campfire.test:8081"
get room_path(@room)
assert_response :success
end
test "cache can be disabled" do
ResponseCache.instance.stubs(:budget).returns(0)
ResponseCache.instance.expects(:read).never
2.times { get room_url(@room) }
assert_response :success
end
test "a commit after authentication cannot admit captured user fields" do
Users::SidebarsController.any_instance.stubs(:set_version_headers).with do
foreign_write("UPDATE users SET name = ? WHERE id = ?", "During authentication", users(:david).id)
true
end
get user_sidebar_url(:me)
Users::SidebarsController.any_instance.unstub(:set_version_headers)
ResponseCache.instance.expects(:write).once
get user_sidebar_url(:me)
assert_includes response.body, "During authentication"
end
private
def foreign_write(sql, *bindings)
SQLite3::Database.new(ActiveRecord::Base.connection_db_config.database) do |database|
database.execute(sql, bindings)
end
# Fixtures share this thread's query cache outside the request executor.
ActiveRecord::Base.clear_query_caches_for_current_thread
end
end
+144
View File
@@ -0,0 +1,144 @@
require "test_helper"
class FragmentRenderingTest < ActionDispatch::IntegrationTest
self.use_transactional_tests = false
setup do
host! "once.campfire.test"
sign_in :david
@previous_caching = ActionController::Base.perform_caching
@previous_message_caching = MessagesController.perform_caching
@class_store = ApplicationController.cache_store
@global_store = Rails.cache
ActionController::Base.perform_caching = true
MessagesController.perform_caching = true
FragmentCache.store.clear
ResponseCache.instance.clear
@room = rooms(:watercooler)
@message = @room.messages.ordered.last
end
teardown do
ActionController::Base.perform_caching = @previous_caching
MessagesController.perform_caching = @previous_message_caching
FragmentCache.store.clear
ResponseCache.instance.clear
end
test "native collection caching uses the bounded store without replacing shared stores" do
assert_same FragmentCache.store, ApplicationController.new.cache_store
assert_same FragmentCache.store, ActionView::PartialRenderer.collection_cache
assert_same @class_store, ApplicationController.cache_store
assert_same @global_store, Rails.cache
ResponseCache.instance.stubs(:budget).returns(0)
get room_messages_url(@room)
assert_response :success
hits = []
ActiveSupport::Notifications.subscribed(->(event) { hits.concat(event.payload[:hits]) }, "cache_read_multi.active_support") do
get room_messages_url(@room)
end
assert_response :success
assert_not_empty hits
token = users(:david).sessions.order(:id).last.token
assert_not_includes hits.join, token
end
test "disabled page caching retains fresh native fragments after foreign leaf writes" do
ResponseCache.instance.stubs(:budget).returns(0)
get room_messages_url(@room)
assert_response :success
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign disabled body", @message.id)
get room_messages_url(@room)
assert_response :success
assert_includes response.body, "foreign disabled body"
end
test "conditional pagination invalidates warmed native fragments without timestamp changes" do
get room_messages_url(@room), headers: { "If-None-Match" => "unmatched" }
assert_response :success
etag = response.headers["ETag"]
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign conditional body", @message.id)
get room_messages_url(@room), headers: { "If-None-Match" => etag }
assert_response :success
assert_includes response.body, "foreign conditional body"
end
test "a foreign commit after capture bypasses old native fragment lookups" do
ResponseCache.instance.stubs(:budget).returns(0)
get room_messages_url(@room)
assert_response :success
MessagesController.any_instance.stubs(:set_version_headers).with do
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign before fragments", @message.id)
true
end
get room_messages_url(@room), headers: { "If-None-Match" => "unmatched" }
assert_response :success
assert_includes response.body, "foreign before fragments"
end
test "refresh streams recheck native cached creator body and boost presentation" do
message = messages(:first)
room = message.room
get room_refresh_url(room, format: :turbo_stream), params: { since: 0 }
assert_response :success
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign refreshed body", message.id)
foreign_write("UPDATE users SET name = ? WHERE id = ?", "Foreign refreshed creator", message.creator_id)
foreign_write("UPDATE boosts SET content = ? WHERE message_id = ?", "Foreign refreshed boost", message.id)
get room_refresh_url(room, format: :turbo_stream), params: { since: 0 }
assert_response :success
assert_includes response.body, "foreign refreshed body"
assert_includes response.body, "Foreign refreshed creator"
assert_includes response.body, "Foreign refreshed boost"
end
test "Jbuilder fragments use the bounded store and do not create HTML token state" do
api = open_session { |session| session.host! "once.campfire.test" }
path = room_bot_messages_path(@room, users(:bender).bot_key)
api.get path
assert_equal 200, api.response.status
assert_not api.cookies["_campfire_session"]
hits = []
ActiveSupport::Notifications.subscribed(->(event) { hits << event.payload[:key] if event.payload[:hit] }, "cache_read.active_support") do
api.get path
end
assert_equal 200, api.response.status
assert_not_empty hits
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign JSON body", @message.id)
foreign_write("UPDATE users SET name = ? WHERE id = ?", "Foreign JSON creator", @message.creator_id)
api.get path
json = JSON.parse(api.response.body).find { |message| message["id"] == @message.id }
assert_includes json.dig("body", "html"), "foreign JSON body"
assert_equal "Foreign JSON creator", json.dig("creator", "name")
api.host! "another.campfire.test:8081"
api.get path
json = JSON.parse(api.response.body).find { |message| message["id"] == @message.id }
assert_includes json["url"], "another.campfire.test:8081"
assert_same @global_store, Rails.cache
end
test "detached broadcasts bypass fragments without a pre-render model snapshot" do
get room_messages_url(@room)
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign broadcast body", @message.id)
FragmentCache.store.expects(:read).never
FragmentCache.store.expects(:write).never
FragmentCache.store.expects(:read_multi).never
FragmentCache.store.expects(:write_multi).never
@message.reload.broadcast_create
assert_rendered_turbo_stream_broadcast @room, :messages, action: "append", target: [ @room, :messages ] do |stream|
assert_includes stream.to_html, "foreign broadcast body"
end
body = ApplicationController.render(partial: "messages/message", locals: { message: @message.reload })
assert_includes body, "foreign broadcast body"
collection = ApplicationController.render(partial: "messages/message", collection: [ @message.reload ], cached: true)
assert_includes collection, "foreign broadcast body"
assert_not ApplicationController.new.perform_caching
end
private
def foreign_write(sql, *bindings)
SQLite3::Database.new(ActiveRecord::Base.connection_db_config.database) do |database|
database.execute(sql, bindings)
end
ActiveRecord::Base.clear_query_caches_for_current_thread
end
end
@@ -1,6 +1,9 @@
require "test_helper"
require "active_record/testing/query_assertions"
class Messages::ByBotsControllerTest < ActionDispatch::IntegrationTest
include ActiveRecord::Assertions::QueryAssertions
setup do
@room = rooms(:watercooler)
end
@@ -93,7 +96,9 @@ class Messages::ByBotsControllerTest < ActionDispatch::IntegrationTest
@room.messages.create!(body: "Filler #{i}", creator: users(:jason), client_message_id: "filler-#{i}")
end
get room_bot_messages_url(@room, users(:bender).bot_key)
assert_no_queries_match(/SELECT COUNT\(\*\) FROM "messages"/i) do
get room_bot_messages_url(@room, users(:bender).bot_key)
end
assert_response :success
json = JSON.parse(response.body)
@@ -119,7 +124,10 @@ class Messages::ByBotsControllerTest < ActionDispatch::IntegrationTest
end
test "index in a room with no messages" do
get room_bot_messages_url(rooms(:bender_and_kevin), users(:bender).bot_key)
room = rooms(:bender_and_kevin)
assert_equal 0, room.messages_count
get room_bot_messages_url(room, users(:bender).bot_key)
assert_response :success
assert_equal [], JSON.parse(response.body)
@@ -19,15 +19,4 @@ class Sessions::TransfersControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to root_url
assert parsed_cookies.signed[:session_token]
end
test "update rejects a link made before the password changed" do
user = users(:david)
transfer_id = user.transfer_id
user.update!(password: "a new secret123456")
put session_transfer_url(transfer_id)
assert_response :bad_request
assert_nil parsed_cookies.signed[:session_token]
end
end
@@ -27,148 +27,9 @@ class Users::ProfilesControllerTest < ActionDispatch::IntegrationTest
assert_equal "david@37signals.com", users(:david).email_address
end
test "changing the password signs out the account's other devices but not this browser" do
other_device = open_session
other_device.post session_url, params: { email_address: users(:david).email_address, password: "secret123456" }
other_device.get user_profile_url
other_device.assert_response :success
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_redirected_to user_profile_url
other_device.get user_profile_url
other_device.assert_redirected_to new_session_url
get user_profile_url
assert_response :success
assert_equal 1, users(:david).sessions.count
end
test "changing the password gives this browser a new session, so a copy of its cookie stops working" do
copied_cookie = cookies[:session_token]
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_not_equal copied_cookie, cookies[:session_token]
copy = open_session
copy.cookies[:session_token] = copied_cookie
copy.get user_profile_url
copy.assert_redirected_to new_session_url
end
test "changing the password along with the avatar still signs this browser back in and closes live connections" do
remote_connections = mock
remote_connections.expects(:disconnect).with(reconnect: true)
ActionCable.server.stubs(:remote_connections).returns(mock.tap { |m| m.expects(:where).with(current_user: users(:david)).returns(remote_connections) })
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456", avatar: fixture_file_upload("moon.jpg", "image/jpeg") } }
assert_redirected_to user_profile_url
assert users(:david).reload.avatar.attached?
get user_profile_url
assert_response :success
end
test "changing the password closes the account's live connections" do
remote_connections = mock
remote_connections.expects(:disconnect).with(reconnect: true)
ActionCable.server.stubs(:remote_connections).returns(mock.tap { |m| m.expects(:where).with(current_user: users(:david)).returns(remote_connections) })
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_redirected_to user_profile_url
end
test "changing the password still signs out other sessions when the realtime service is unreachable" do
other_session = users(:david).sessions.first
ActionCable.server.stubs(:remote_connections).raises(RuntimeError.new("cable down"))
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_redirected_to user_profile_url
assert_not Session.exists?(other_session.id)
end
test "updating the profile without a new password leaves other sessions signed in" do
ActionCable.server.expects(:remote_connections).never
assert_no_difference -> { users(:david).sessions.count } do
put user_profile_url, params: { user: { name: "David", password: "" } }
end
assert_redirected_to user_profile_url
end
test "changing the password with the current one" do
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_redirected_to user_profile_url
assert_equal "✓", flash[:notice]
assert users(:david).reload.authenticate("a new secret123456")
end
test "changing the password without the current one changes nothing" do
assert_password_change_refused "Enter your current password to change it.",
user: { name: "Someone else", password: "a new secret123456" }
end
test "changing the password with a wrong current one changes nothing" do
assert_password_change_refused "That isn't your current password.",
user: { name: "Someone else", password: "a new secret123456", password_challenge: "not my password" }
end
test "editing the profile without changing the password doesn't need the current one" do
put user_profile_url, params: { user: { name: "Dave", bio: "Acrobat", password: "" } }
assert_redirected_to user_profile_url
assert_equal "Dave", users(:david).reload.name
assert users(:david).authenticate("secret123456")
end
test "attempts to change the password are rate limited like signing in" do
Users::ProfilesController.cache_store.stubs(:increment).returns(11)
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_redirected_to user_profile_url
assert_equal "Too many attempts. Try again in a few minutes.", flash[:alert]
assert users(:david).reload.authenticate("secret123456")
put user_profile_url, params: { user: { name: "Dave" } }
assert_equal "Dave", users(:david).reload.name
end
test "changing the password removes the other devices' push subscriptions, and this browser can register its own again" do
stub_web_push_dns_resolution
endpoint = push_subscriptions(:david_chrome).attributes.slice("endpoint", "p256dh_key", "auth_key")
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_empty users(:david).push_subscriptions
post user_push_subscriptions_url, params: { push_subscription: endpoint }
assert_response :ok
assert_equal [ endpoint["endpoint"] ], users(:david).push_subscriptions.pluck(:endpoint)
end
test "updates are limited to the current user" do
put user_profile_url(users(:jason)), params: { user: { name: "John Doe" } }
assert_equal "Jason", users(:jason).reload.name
end
private
def assert_password_change_refused(alert, params)
other_device = open_session
other_device.post session_url, params: { email_address: users(:david).email_address, password: "secret123456" }
ActionCable.server.expects(:remote_connections).never
assert_no_changes -> { [ Session.count, Push::Subscription.count ] } do
put user_profile_url, params: params
end
assert_redirected_to user_profile_url
assert_equal alert, flash[:alert]
assert users(:david).reload.authenticate("secret123456")
assert_equal "David", users(:david).name
other_device.get user_profile_url
other_device.assert_response :success
end
end
@@ -74,98 +74,4 @@ class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to user_push_subscriptions_url
end
end
test "a signed-out session can't register a subscription" do
Session.delete_all
assert_no_difference -> { Push::Subscription.count } do
post user_push_subscriptions_url, params: { push_subscription: new_subscription_params }
end
assert_redirected_to new_session_url
end
test "a session signed out while its request is in flight doesn't register a subscription" do
Push::Subscription.any_instance.stubs(:valid?).with { Session.delete_all; true }.returns(true)
assert_no_difference -> { Push::Subscription.count } do
post user_push_subscriptions_url, params: { push_subscription: new_subscription_params }
end
assert_response :unauthorized
end
test "a session signed out while its request is in flight doesn't keep an existing subscription alive" do
subscription = push_subscriptions(:david_chrome)
Push::Subscription.any_instance.stubs(:valid?).with { Session.delete_all; true }.returns(true)
assert_no_changes -> { subscription.reload.updated_at } do
post user_push_subscriptions_url, params: { push_subscription: subscription.attributes.slice("endpoint", "p256dh_key", "auth_key") }
end
assert_response :unauthorized
end
test "pages name their session for the browser to post its subscription again in a new one" do
get user_profile_url
assert_equal user_push_subscriptions_path, css_select("[data-push-subscription-sync-url-value]").first["data-push-subscription-sync-url-value"]
get room_url(rooms(:watercooler))
first_marker = css_select("[data-push-subscription-sync-session-value]").first["data-push-subscription-sync-session-value"]
assert_match(/\A\h{64}\z/, first_marker)
assert_not_includes response.body, users(:david).sessions.order(:id).last.token
sign_in :david
get room_url(rooms(:watercooler))
assert_not_equal first_marker, css_select("[data-push-subscription-sync-session-value]").first["data-push-subscription-sync-session-value"]
end
test "two posts of the same new subscription at once register it once" do
Push::Subscription.any_instance.stubs(:valid?).with do
users(:david).push_subscriptions.build(new_subscription_params).save!(validate: false) unless Push::Subscription.exists?(endpoint: new_subscription_params["endpoint"])
true
end.returns(true)
assert_difference -> { Push::Subscription.where(endpoint: new_subscription_params["endpoint"]).count }, +1 do
post user_push_subscriptions_url, params: { push_subscription: new_subscription_params }
end
assert_response :ok
end
private
def new_subscription_params
{ "endpoint" => "https://fcm.googleapis.com/fcm/send/abc123", "p256dh_key" => "123", "auth_key" => "456" }
end
end
class Users::PushSubscriptionsControllerForeignWriteTest < ActionDispatch::IntegrationTest
self.use_transactional_tests = false
setup do
sign_in :david
stub_web_push_dns_resolution
end
test "a subscription another connection registers while this one validates isn't registered twice" do
params = { "endpoint" => "https://fcm.googleapis.com/fcm/send/foreign", "p256dh_key" => "123", "auth_key" => "456" }
Push::Subscription.any_instance.stubs(:valid?).with { foreign_insert(params); true }.returns(true)
post user_push_subscriptions_url, params: { push_subscription: params }
assert_response :ok
assert_equal 1, Push::Subscription.where(endpoint: params["endpoint"]).count
end
private
def foreign_insert(params)
unless @inserted
SQLite3::Database.new(ActiveRecord::Base.connection_db_config.database) do |database|
database.execute "INSERT INTO push_subscriptions (user_id, endpoint, p256dh_key, auth_key, created_at, updated_at) " \
"VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))", [ users(:david).id, *params.values ]
end
@inserted = true
end
end
end
+1 -1
View File
@@ -172,7 +172,7 @@ class ContentFiltersTest < ActionView::TestCase
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body).to_html
assert_dom_equal body, filtered
assert_equal body, filtered
assert_match %r{<table>.*<th><p>Name</p></th>.*<td><p>Jason</p></td>}m, message_presentation(message)
end
-41
View File
@@ -25,47 +25,6 @@ class MessagesHelperTest < ActionView::TestCase
assert_match /<strong>bold<\/strong>/, presentation
end
test "message_presentation links URLs and email addresses in text" do
message = Message.create! room: rooms(:pets), body: "<div>See https://example.com/a?b=1&amp;c=2, or write to me@example.com.</div>", client_message_id: "0015", creator: users(:jason)
links = Nokogiri::HTML5.fragment(view.message_presentation(message)).css("a")
assert_equal [ "https://example.com/a?b=1&c=2", "mailto:me@example.com" ], links.map { it["href"] }
assert_equal [ "_blank", "_blank" ], links.map { it["target"] }
assert_equal [ "https://example.com/a?b=1&c=2", "me@example.com" ], links.map(&:text)
end
test "message_presentation links an email address with an apostrophe whole" do
message = Message.create! room: rooms(:pets), body: "<div>Write to o'brien@example.com</div>", client_message_id: "0015", creator: users(:jason)
links = Nokogiri::HTML5.fragment(view.message_presentation(message)).css("a")
assert_equal [ [ "mailto:o%27brien@example.com", "o'brien@example.com" ] ], links.map { [ it["href"], it.text ] }
end
test "message_presentation leaves URLs and email addresses in attribute values as they are" do
[ "class", "title" ].product([ "https://example.com", "www.example.com", "me@example.com" ]).each do |attribute, linkable|
value = "a> #{linkable} <em>b</em> "
message = Message.create! room: rooms(:pets), body: %(<div><span #{attribute}="#{value}">c</span> https://example.org</div>), client_message_id: "0015", creator: users(:jason)
presentation = Nokogiri::HTML5.fragment(view.message_presentation(message))
assert_equal [ value ], presentation.css("span[#{attribute}]").map { it[attribute] }, "#{attribute} with #{linkable}"
assert_empty presentation.css("em"), "#{attribute} with #{linkable}"
assert_equal [ "https://example.org" ], presentation.css("a").map { it["href"] }, "#{attribute} with #{linkable}"
end
end
test "linking text doesn't eat a code block's leading blank line" do
linked = view.send(:auto_link_text, "<pre>\n\n\nfirst https://example.com</pre>".html_safe)
assert_equal "\nfirst https://example.com", Nokogiri::HTML5.fragment(linked).at_css("pre").text
end
test "message_presentation leaves an existing link's text alone" do
message = Message.create! room: rooms(:pets), body: '<div><a href="https://example.com/">https://example.org/</a></div>', client_message_id: "0015", creator: users(:jason)
links = Nokogiri::HTML5.fragment(view.message_presentation(message)).css("a")
assert_equal [ [ "https://example.com/", "https://example.org/" ] ], links.map { [ it["href"], it.text ] }
end
test "message_presentation shows an image's thumbnail made when it was posted" do
presentation = view.message_presentation(attachment_message("moon.jpg", "image/jpeg", processed: true))
@@ -1,187 +0,0 @@
require "test_helper"
class ContentSecurityPolicyTest < ActionDispatch::IntegrationTest
PAGE_PATH = "/"
setup { sign_in :david }
test "enforcing policy allows exactly the inline scripts the page carries" do
get root_url
follow_redirect! while response.redirect?
policy = response.headers["Content-Security-Policy"]
assert policy, "expected an enforcing Content-Security-Policy header"
assert_nil response.headers["Content-Security-Policy-Report-Only"]
assert_match "object-src 'none'", policy
assert_match "base-uri 'self'", policy
assert_no_match "unsafe-inline", policy
assert_no_match "nonce-", policy
assert_equal "www.example.com/service-worker.js", policy[/worker-src ([^;]+)/, 1]
script_src = policy[/script-src ([^;]+)/, 1].split
assert_not_includes script_src, "'self'"
assert_includes script_src, "www.example.com/assets/"
inline = Nokogiri::HTML5(response.body).css("script").reject { |s| s["src"] || s["type"] == "text/template" }
assert_operator inline.size, :>=, 2
inline.each do |script|
assert_includes script_src, "'sha256-#{Digest::SHA256.base64digest(script.text)}'", "inline #{script["type"]} script not allowed by hash"
end
end
test "scripts may load from a configured asset host, however it's written" do
{
"https://cdn.example.test" => %r{\Ahttps://cdn\.example\.test\z},
"cdn.example.test" => %r{\Ahttp://cdn\.example\.test\z},
"//cdn.example.test" => %r{\Ahttp://cdn\.example\.test\z},
"https://assets%d.example.test" => %r{\Ahttps://assets[0-3]\.example\.test\z},
->(source) { "https://one.example.test" } => %r{\Ahttps://one\.example\.test\z},
->(source, request) { "https://#{request.host}.example.test" } => %r{\Ahttps://www\.example\.com\.example\.test\z}
}.each do |asset_host, expected_origin|
with_asset_host(asset_host) do
get PAGE_PATH
follow_redirect! while response.redirect?
assert_response :success
script_src = response.headers["Content-Security-Policy"][/script-src ([^;]+)/, 1].split
page = Nokogiri::HTML5(response.body)
urls = JSON.parse(page.at_css("script[type=importmap]").text)["imports"].values +
page.css("link[rel=modulepreload]").map { it["href"] } + page.css("script[src]").map { it["src"] }
origins = urls.map { URI.join(request.base_url, it).origin }.uniq - [ request.base_url ]
assert_not_empty origins, asset_host.inspect
origins.each { assert_match expected_origin, it, asset_host.inspect }
assert_scripts_allowed_by_path script_src, urls.map { URI.join(request.base_url, it).to_s }, asset_host.inspect
assert_includes script_src, "'sha256-#{Digest::SHA256.base64digest(page.at_css("script[type=importmap]").text)}'", asset_host.inspect
end
end
end
test "an asset host whose path contains the asset directory is limited to the directory it serves" do
with_asset_host("https://cdn.example.test/assets") do
get PAGE_PATH
follow_redirect! while response.redirect?
script_src = response.headers["Content-Security-Policy"][/script-src ([^;]+)/, 1].split
assert_includes script_src, "https://cdn.example.test/assets/assets/"
assert_not allowed_by_path?(script_src, "https://cdn.example.test/assets/rails/active_storage/blobs/proxy/x/upload.js")
end
end
test "the sign-in page is limited to the asset directory too" do
cookies.delete(:session_token)
get new_session_url
assert_response :success
script_src = response.headers["Content-Security-Policy"][/script-src ([^;]+)/, 1].split
assert_not_includes script_src, "'self'"
assert_includes script_src, "www.example.com/assets/"
end
test "scripts are limited to the asset directory under a relative root" do
with_relative_url_root("/chat") do
get PAGE_PATH
follow_redirect! while response.redirect?
assert_response :success
script_src = response.headers["Content-Security-Policy"][/script-src ([^;]+)/, 1].split
page = Nokogiri::HTML5(response.body)
urls = JSON.parse(page.at_css("script[type=importmap]").text)["imports"].values + page.css("link[rel=modulepreload]").map { it["href"] }
assert urls.all? { it.start_with?("/chat/assets/") }, urls.inspect
assert_includes script_src, "www.example.com/chat/assets/"
assert_scripts_allowed_by_path script_src, urls.map { URI.join(request.base_url, it).to_s }
end
end
test "this origin's sources keep a non-default port and take the page's scheme" do
host! "www.example.com:8443"
get PAGE_PATH
follow_redirect! while response.redirect?
policy = response.headers["Content-Security-Policy"]
assert_includes policy[/script-src ([^;]+)/, 1].split, "www.example.com:8443/assets/"
assert_equal "www.example.com:8443/service-worker.js", policy[/worker-src ([^;]+)/, 1]
end
test "uploads can't load as scripts" do
get PAGE_PATH
follow_redirect! while response.redirect?
script_src = response.headers["Content-Security-Policy"][/script-src ([^;]+)/, 1].split
blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new("alert(1)"), filename: "upload.js", content_type: "text/javascript")
disk_url = ActiveStorage::Current.set(url_options: { protocol: "http", host: "www.example.com" }) { blob.url }
[ rails_blob_url(blob), rails_blob_url(blob, disposition: :inline), rails_storage_proxy_url(blob), disk_url ].each do |url|
assert_not allowed_by_path?(script_src, url), "#{url} allowed by #{script_src.inspect}"
end
end
test "with the policy turned off, pages send none" do
without_policy do
get PAGE_PATH
follow_redirect! while response.redirect?
assert_response :success
assert_nil response.headers["Content-Security-Policy"]
end
end
test "the escape hatch boots without a policy" do
output = IO.popen({ "DISABLE_CONTENT_SECURITY_POLICY" => "true" },
[ "bin/rails", "runner", "print Rails.application.config.content_security_policy.inspect" ], chdir: Rails.root, err: File::NULL, &:read)
assert_equal "nil", output
end
test "uploaded files are guarded on their way out" do
assert_includes Rails.application.middleware.map(&:klass), UploadedScriptGuard
end
private
# What DISABLE_CONTENT_SECURITY_POLICY=true leaves at boot: no application policy
def without_policy
previous = Rails.application.env_config.delete("action_dispatch.content_security_policy")
yield
ensure
Rails.application.env_config["action_dispatch.content_security_policy"] = previous
end
# Every page script is allowed by a source with a path, never by a bare origin
def assert_scripts_allowed_by_path(script_src, urls, message = nil)
assert_empty script_src.grep(%r{\A([a-z]+://)?[^/']+/?\z}), message
urls.each { assert allowed_by_path?(script_src, it), "#{it} not allowed by #{script_src.inspect} #{message}" }
end
# CSP source matching on a first request: 'self' and a bare origin match any
# path, a path ending in / matches as a prefix, any other path exactly. A
# source without a scheme takes the page's.
def allowed_by_path?(script_src, url)
url = URI(url)
script_src.any? do |source|
unless source.start_with?("'") && source != "'self'"
source = URI(source == "'self'" ? request.base_url : (source.include?("://") ? source : "#{request.scheme}://#{source}"))
end
source.is_a?(URI) && source.origin == url.origin &&
(source.path.empty? || (source.path.end_with?("/") ? url.path.start_with?(source.path) : url.path == source.path))
end
end
def with_relative_url_root(root)
previous, ApplicationController.config.relative_url_root = ApplicationController.config.relative_url_root, root
Rails.application.importmap.send(:clear_cache)
yield
ensure
ApplicationController.config.relative_url_root = previous
Rails.application.importmap.send(:clear_cache)
end
def with_asset_host(asset_host)
previous, ApplicationController.asset_host = ApplicationController.asset_host, asset_host
Rails.application.importmap.send(:clear_cache)
yield
ensure
ApplicationController.asset_host = previous
Rails.application.importmap.send(:clear_cache)
end
end
@@ -1,42 +0,0 @@
require "test_helper"
class UploadedScriptGuardTest < ActiveSupport::TestCase
# Every JavaScript MIME type essence in the MIME Sniffing Standard, plus
# parameters and casing a signed Disk URL might carry.
SCRIPT_TYPES = %w[
application/ecmascript application/javascript application/x-ecmascript application/x-javascript
text/ecmascript text/javascript text/javascript1.0 text/javascript1.1 text/javascript1.2 text/javascript1.3
text/javascript1.4 text/javascript1.5 text/jscript text/livescript text/x-ecmascript text/x-javascript
] + [ "Text/JavaScript; charset=utf-8" ]
test "uploads never leave with a script type, whatever the URL says" do
SCRIPT_TYPES.each do |type|
assert_equal ActiveStorage.binary_content_type, served_type(type, "#{ActiveStorage.routes_prefix}/disk/key/probe.js"), type
end
end
test "media and downloads keep their type" do
%w[ image/png video/mp4 audio/mpeg application/pdf text/plain ].each do |type|
assert_equal type, served_type(type, "#{ActiveStorage.routes_prefix}/disk/key/file"), type
end
end
test "multi-range responses keep their boundary" do
type = "multipart/byteranges; boundary=AaB03x"
assert_equal type, served_type(type, "#{ActiveStorage.routes_prefix}/blobs/proxy/key/movie.mp4")
end
test "direct upload responses keep their JSON type" do
assert_equal "application/json", served_type("application/json", "#{ActiveStorage.routes_prefix}/direct_uploads", method: "POST")
end
test "application responses outside uploads are untouched" do
assert_equal "text/javascript", served_type("text/javascript", "/assets/application-abc123.js")
end
private
def served_type(type, path, method: "GET")
app = ->(env) { [ 200, { "content-type" => type }, [ "" ] ] }
UploadedScriptGuard.new(app).call(Rack::MockRequest.env_for(path, method: method))[1]["content-type"]
end
end
-18
View File
@@ -74,24 +74,6 @@ class Opengraph::FetchTest < ActiveSupport::TestCase
end
end
test "#fetch_document connects to the resolved IP even when a proxy is configured" do
url = URI.parse("http://www.example.com/")
saved = ENV.slice("http_proxy", "HTTP_PROXY", "no_proxy", "NO_PROXY")
%w[ no_proxy NO_PROXY ].each { |k| ENV.delete(k) }
%w[ http_proxy HTTP_PROXY ].each { |k| ENV[k] = "http://proxy.internal:3128" }
WebMock.disable_net_connect! allow: [ url.host ]
TCPSocket.expects(:open).with { |*args, **| args.first == "proxy.internal" }.never
TCPSocket.expects(:open).with { |*args, **| args.first == "1.2.3.4" && args[1] == 80 }.throws(:not_proxied)
assert_throws :not_proxied do
@fetch.fetch_document(url, ip: "1.2.3.4")
end
ensure
%w[ http_proxy HTTP_PROXY no_proxy NO_PROXY ].each { |k| ENV.delete(k) }
saved.each { |k, v| ENV[k] = v }
end
test "#fetch_document is empty following redirects that never finish" do
WebMock.stub_request(:get, "https://www.example.com/")
.to_return(status: 302, headers: { location: "https://www.example.com/" })
+88
View File
@@ -0,0 +1,88 @@
require "test_helper"
require "tmpdir"
class ResponseCacheTest < ActiveSupport::TestCase
setup do
@directory = Dir.mktmpdir
@database = File.join(@directory, "cache.sqlite3")
SQLite3::Database.new(@database) do |database|
database.execute("PRAGMA journal_mode=WAL")
database.execute("CREATE TABLE values_for_test (value TEXT)")
end
ActiveRecord::Base.stubs(:connection_db_config).returns(Struct.new(:database).new(@database))
@cache = ResponseCache.new
@cache.stubs(:budget).returns(1024)
end
teardown do
@cache.clear
FileUtils.remove_entry(@directory)
end
test "foreign commits expire entries and reject old render admission" do
version = @cache.version
@cache.write("page", version, entry("first"))
assert_equal "first", @cache.read("page", version)[:body]
SQLite3::Database.new(@database) { |database| database.execute("INSERT INTO values_for_test VALUES ('committed')") }
assert_nil @cache.read("page", version)
@cache.write("page", version, entry("old in-flight render"))
assert_nil @cache.read("page", @cache.version)
@cache.write("page", @cache.version, entry("fresh"))
assert_equal "fresh", @cache.read("page", @cache.version)[:body]
end
test "byte budget evicts oldest and bypasses oversized entries and keys" do
version = @cache.version
@cache.stubs(:budget).returns(512)
@cache.write("first", version, entry("a" * 100))
@cache.write("second", version, entry("b" * 100))
assert_nil @cache.read("first", version)
assert_equal "b" * 100, @cache.read("second", version)[:body]
@cache.write("oversized", version, entry("c" * 1000))
assert_nil @cache.read("oversized", version)
@cache.write("k" * 2049, version, entry("small"))
assert_nil @cache.read("k" * 2049, version)
end
test "clearing the observer gives later pages a distinct namespace" do
version = @cache.version
@cache.clear
assert_not_equal version, @cache.version
end
test "cold renders coalesce without holding the observer mutex" do
version = @cache.version
entered = Queue.new
release = Queue.new
first = Thread.new do
@cache.synchronize_render("page", version) do
entered << true
release.pop
@cache.write("page", version, entry("completed render"))
end
end
entered.pop
second_started = Queue.new
second = Thread.new do
second_started << true
@cache.synchronize_render("page", version) { @cache.read("page", version) }
end
second_started.pop
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
Thread.pass until second.status == "sleep" || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
assert_equal "sleep", second.status
assert_nil @cache.read("page", version)
assert_equal version, @cache.version
release << true
first.value
assert_equal "completed render", second.value[:body]
ensure
release << true if release
[ first, second ].compact.each { |thread| thread.join(2) }
end
private
def entry(body)
{ body: body, marker: "unexposed-marker", headers: {} }
end
end
@@ -0,0 +1,237 @@
require "test_helper"
require "sqlite3"
# Destructive trigger DDL and foreign connections need their own file so parallel
# workers (and transactional tests in messages_count_test) keep a stable trigger set.
class Room::MessagesCountLifecycleTest < ActiveSupport::TestCase
self.use_transactional_tests = false
setup do
Room::MessagesCount.ensure!
@room = rooms(:designers)
@other_room = rooms(:pets)
Room::MessagesCount.backfill!
@room.reload
@other_room.reload
end
teardown do
Message.where("client_message_id LIKE ?", "count-%").delete_all
Room::MessagesCount.ensure!
Room::MessagesCount.backfill!
end
test "ensure! installs missing triggers and keeps accurate counts" do
before = @room.reload.messages_count
Room::MessagesCount.uninstall!
assert_not Room::MessagesCount.triggers_installed?
Room::MessagesCount.ensure!
assert Room::MessagesCount.triggers_installed?
assert_equal before, @room.reload.messages_count
assert_equal @room.messages.count, @room.messages_count
assert_difference -> { @room.reload.messages_count }, +1 do
@room.messages.create!(creator: users(:jason), body: "After ensure", client_message_id: "count-ensure")
end
end
test "ensure! repairs a partial trigger install and backfills drifted counts" do
Room::MessagesCount.uninstall!
ActiveRecord::Base.connection.execute <<~SQL
CREATE TRIGGER #{Room::MessagesCount::INSERT_TRIGGER} AFTER INSERT ON messages
BEGIN
UPDATE rooms SET messages_count = messages_count + 1 WHERE id = NEW.room_id;
END
SQL
assert Room::MessagesCount.trigger_installed?(ActiveRecord::Base.connection, Room::MessagesCount::INSERT_TRIGGER)
assert_not Room::MessagesCount.trigger_installed?(ActiveRecord::Base.connection, Room::MessagesCount::DELETE_TRIGGER)
# Delete fires with no delete trigger → counter drifts high.
drifted = @room.messages.create!(creator: users(:jason), body: "Drift", client_message_id: "count-partial-drift")
ActiveRecord::Base.connection.execute("DELETE FROM messages WHERE id = #{drifted.id}")
assert_operator @room.reload.messages_count, :>, @room.messages.count
Room::MessagesCount.ensure!
assert Room::MessagesCount.triggers_installed?
assert_equal @room.messages.count, @room.reload.messages_count
end
test "ensure! backfills writes that landed while the insert trigger was missing" do
Room::MessagesCount.uninstall!
before_count = @room.messages.count
before_cached = @room.reload.messages_count
assert_equal before_count, before_cached
path = File.expand_path(ActiveRecord::Base.connection_db_config.database)
now = Time.current.utc.strftime("%Y-%m-%d %H:%M:%S.%6N")
ActiveRecord::Base.connection_pool.release_connection
SQLite3::Database.new(path) do |db|
db.busy_timeout = 5_000
db.execute(
"INSERT INTO messages (room_id, creator_id, client_message_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
[ @room.id, users(:david).id, "count-missing-insert", now, now ]
)
end
assert_equal before_count + 1, @room.messages.count
assert_equal before_cached, @room.reload.messages_count
Room::MessagesCount.ensure!
assert Room::MessagesCount.triggers_installed?
assert_equal @room.messages.count, @room.reload.messages_count
assert_equal before_cached + 1, @room.messages_count
end
test "concurrent ensure! repairs leave triggers installed and counts accurate" do
Room::MessagesCount.uninstall!
path = File.expand_path(ActiveRecord::Base.connection_db_config.database)
now = Time.current.utc.strftime("%Y-%m-%d %H:%M:%S.%6N")
ActiveRecord::Base.connection_pool.release_connection
SQLite3::Database.new(path) do |db|
db.busy_timeout = 5_000
db.execute(
"INSERT INTO messages (room_id, creator_id, client_message_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
[ @room.id, users(:david).id, "count-concurrent-drift", now, now ]
)
end
assert_not_equal @room.messages.count, @room.reload.messages_count
errors = []
errors_mutex = Mutex.new
workers = 2.times.map do
Thread.new do
ActiveRecord::Base.connection_pool.with_connection do |connection|
Room::MessagesCount.ensure!(connection)
end
rescue => error
errors_mutex.synchronize { errors << error }
end
end
workers.each(&:join)
assert_empty errors, -> { errors.map(&:full_message).join("\n") }
assert Room::MessagesCount.triggers_installed?
assert_equal @room.messages.count, @room.reload.messages_count
end
test "writes concurrent with ensure! repair leave accurate counts" do
Room::MessagesCount.uninstall!
path = File.expand_path(ActiveRecord::Base.connection_db_config.database)
now = Time.current.utc.strftime("%Y-%m-%d %H:%M:%S.%6N")
ActiveRecord::Base.connection_pool.release_connection
errors = []
errors_mutex = Mutex.new
ensure_thread = Thread.new do
ActiveRecord::Base.connection_pool.with_connection do |connection|
Room::MessagesCount.ensure!(connection)
end
rescue => error
errors_mutex.synchronize { errors << error }
end
insert_thread = Thread.new do
SQLite3::Database.new(path) do |db|
db.busy_timeout = 10_000
db.execute(
"INSERT INTO messages (room_id, creator_id, client_message_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
[ @room.id, users(:david).id, "count-during-repair", now, now ]
)
end
rescue => error
errors_mutex.synchronize { errors << error }
end
[ ensure_thread, insert_thread ].each(&:join)
assert_empty errors, -> { errors.map(&:full_message).join("\n") }
assert Room::MessagesCount.triggers_installed?
assert_equal @room.messages.count, @room.reload.messages_count
assert Message.exists?(client_message_id: "count-during-repair")
end
test "install! never exposes a partial trigger set to other connections" do
Room::MessagesCount.uninstall!
path = File.expand_path(ActiveRecord::Base.connection_db_config.database)
ActiveRecord::Base.connection_pool.release_connection
stop = false
partial_snapshots = []
snapshots_mutex = Mutex.new
watcher = Thread.new do
SQLite3::Database.new(path) do |db|
db.busy_timeout = 5_000
until stop
names = db.execute(
"SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN (#{Room::MessagesCount::TRIGGERS.map { "'#{it}'" }.join(", ")})"
).flatten
if names.any? && names.sort != Room::MessagesCount::TRIGGERS.sort
snapshots_mutex.synchronize { partial_snapshots << names.sort }
end
end
end
end
25.times { Room::MessagesCount.install! }
stop = true
watcher.join
assert_empty partial_snapshots
assert Room::MessagesCount.triggers_installed?
end
test "foreign SQLite connections keep the counter in step" do
path = File.expand_path(ActiveRecord::Base.connection_db_config.database)
now = Time.current.utc.strftime("%Y-%m-%d %H:%M:%S.%6N")
before = @room.reload.messages_count
other_before = @other_room.reload.messages_count
ActiveRecord::Base.connection_pool.release_connection
SQLite3::Database.new(path) do |db|
db.busy_timeout = 5_000
db.execute(
"INSERT INTO messages (room_id, creator_id, client_message_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
[ @room.id, users(:david).id, "count-foreign-insert", now, now ]
)
end
assert_equal before + 1, @room.reload.messages_count
assert_equal @room.messages.count, @room.messages_count
foreign_id = Message.find_by!(client_message_id: "count-foreign-insert").id
ActiveRecord::Base.connection_pool.release_connection
SQLite3::Database.new(path) do |db|
db.busy_timeout = 5_000
db.execute("UPDATE messages SET room_id = ? WHERE id = ?", [ @other_room.id, foreign_id ])
end
assert_equal before, @room.reload.messages_count
assert_equal other_before + 1, @other_room.reload.messages_count
ActiveRecord::Base.connection_pool.release_connection
SQLite3::Database.new(path) do |db|
db.busy_timeout = 5_000
db.execute("DELETE FROM messages WHERE id = ?", [ foreign_id ])
end
assert_equal before, @room.reload.messages_count
assert_equal other_before, @other_room.reload.messages_count
end
end
@@ -0,0 +1,152 @@
require "test_helper"
require "rake"
require "tmpdir"
Rails.application.load_tasks unless Rake::Task.task_defined?("room_messages_count:ensure")
class Room::MessagesCountTasksTest < ActiveSupport::TestCase
self.use_transactional_tests = false
setup do
@directory = Dir.mktmpdir("campfire-counter-tasks")
@schema = File.join(@directory, "schema.rb")
@migrations = File.join(@directory, "migrations")
Dir.mkdir(@migrations)
File.write(@schema, <<~RUBY)
ActiveRecord::Schema[8.2].define(version: 0) do
create_table :rooms, force: true do |t|
t.integer :messages_count, default: 0, null: false
end
create_table :messages, force: true do |t|
t.integer :room_id
t.string :client_message_id
end
end
RUBY
@tasks = ActiveRecord::Tasks::DatabaseTasks
@original_database_url = ENV.delete("DATABASE_URL")
@original_environment = @tasks.env
@original_configurations = ActiveRecord::Base.configurations
@original_migrations = @tasks.migrations_paths
@original_dump = ActiveRecord.dump_schema_after_migration
@original_seed_loader = @tasks.seed_loader
@tasks.migrations_paths = [ @migrations ]
ActiveRecord.dump_schema_after_migration = false
end
teardown do
ActiveRecord::Base.configurations = @original_configurations
ENV["DATABASE_URL"] = @original_database_url if @original_database_url
@tasks.env = @original_environment
@tasks.migrations_paths = @original_migrations
@tasks.seed_loader = @original_seed_loader
ActiveRecord.dump_schema_after_migration = @original_dump
FileUtils.remove_entry(@directory)
end
test "development schema task repairs both databases before restoring its original pool" do
development = configuration("development")
test = configuration("test")
ActiveRecord::Base.configurations = { "development" => { "primary" => development.configuration_hash }, "test" => { "primary" => test.configuration_hash } }
@tasks.env = "development"
@tasks.with_temporary_connection(development) do |connection|
@tasks.load_schema(development)
original_path = connection.pool.db_config.database
2.times do
task = Rake::Task["db:schema:load"]
task.all_prerequisite_tasks.each(&:reenable)
task.reenable
task.invoke
assert_equal original_path, ActiveRecord::Base.connection_db_config.database
assert Room::MessagesCount.triggers_installed?(connection)
@tasks.with_temporary_connection(test) do |test_connection|
assert Room::MessagesCount.triggers_installed?(test_connection)
assert_counter_tracks_insert(test_connection)
end
end
end
end
test "test schema task repairs the test database selected by purge" do
config = configuration("test")
ActiveRecord::Base.configurations = { "test" => { "primary" => config.configuration_hash } }
@tasks.with_temporary_connection(config) do |connection|
task = Rake::Task["db:test:load_schema"]
task.all_prerequisite_tasks.each(&:reenable)
task.reenable
task.invoke
assert Room::MessagesCount.triggers_installed?(connection)
assert_counter_tracks_insert(connection)
end
end
test "prepare installs schema triggers before loading seeds" do
config = configuration("test")
ActiveRecord::Base.configurations = { "test" => { "primary" => config.configuration_hash } }
seeded = false
loader = Object.new
loader.define_singleton_method(:load_seed) do
connection = ActiveRecord::Base.connection
raise "seeds ran without counter triggers" unless Room::MessagesCount.triggers_installed?(connection)
connection.execute("INSERT INTO rooms (id) VALUES (1)")
connection.execute("INSERT INTO messages (room_id) VALUES (1), (1)")
raise "seed counter is stale" unless connection.select_value("SELECT messages_count FROM rooms WHERE id = 1") == 2
seeded = true
end
@tasks.seed_loader = loader
@tasks.with_temporary_connection(config) do |connection|
@tasks.prepare_all
assert seeded
assert Room::MessagesCount.triggers_installed?(connection)
assert_equal 2, connection.select_value("SELECT messages_count FROM rooms WHERE id = 1")
end
end
test "migrate repairs rebuilt message tables and repeats repair with no new migration" do
config = configuration("test")
File.write(File.join(@migrations, "20261008000100_change_counter_task_message_column.rb"), <<~RUBY)
class ChangeCounterTaskMessageColumn < ActiveRecord::Migration[8.2]
def change
change_column :messages, :client_message_id, :text
end
end
RUBY
@tasks.with_temporary_connection(config) do |connection|
@tasks.load_schema(config)
Room::MessagesCount.ensure!(connection)
assert_counter_tracks_insert(connection)
@tasks.migrate
assert Room::MessagesCount.triggers_installed?(connection)
assert_counter_tracks_insert(connection)
Room::MessagesCount.uninstall!(connection)
connection.execute("INSERT INTO messages (room_id) VALUES (1)")
assert_not_equal connection.select_value("SELECT COUNT(*) FROM messages WHERE room_id = 1"), connection.select_value("SELECT messages_count FROM rooms WHERE id = 1")
@tasks.migrate
assert Room::MessagesCount.triggers_installed?(connection)
assert_equal connection.select_value("SELECT COUNT(*) FROM messages WHERE room_id = 1"), connection.select_value("SELECT messages_count FROM rooms WHERE id = 1")
end
end
private
def configuration(environment)
ActiveRecord::DatabaseConfigurations::HashConfig.new(environment, "primary", {
adapter: "sqlite3", database: File.join(@directory, "#{environment}.sqlite3"),
default_transaction_mode: :immediate, schema_dump: @schema,
migrations_paths: [ @migrations ]
})
end
def assert_counter_tracks_insert(connection)
connection.execute("INSERT INTO rooms (id) VALUES (1) ON CONFLICT DO NOTHING")
before = connection.select_value("SELECT messages_count FROM rooms WHERE id = 1")
connection.execute("INSERT INTO messages (room_id) VALUES (1)")
assert_equal before + 1, connection.select_value("SELECT messages_count FROM rooms WHERE id = 1")
end
end
+86
View File
@@ -0,0 +1,86 @@
require "test_helper"
class Room::MessagesCountTest < ActiveSupport::TestCase
setup do
@room = rooms(:designers)
@other_room = rooms(:pets)
end
test "fixture rooms start with an accurate messages_count" do
[ rooms(:watercooler), rooms(:designers), rooms(:bender_and_kevin) ].each do |room|
assert_equal room.messages.count, room.messages_count, "#{room.name || room.id} fixture count"
end
end
test "ActiveRecord create and destroy adjust the counter once" do
assert_difference -> { @room.reload.messages_count }, +1 do
assert_difference -> { @room.messages.count }, +1 do
@room.messages.create!(creator: users(:jason), body: "Hello", client_message_id: "count-ar-create")
end
end
assert_equal @room.messages.count, @room.reload.messages_count
assert_difference -> { @room.reload.messages_count }, -1 do
assert_difference -> { @room.messages.count }, -1 do
@room.messages.order(:id).last.destroy
end
end
assert_equal @room.messages.count, @room.reload.messages_count
end
test "bulk insert_all and delete_all keep the counter in step" do
rows = Array.new(3) do |i|
{
room_id: @room.id,
creator_id: users(:david).id,
client_message_id: "count-bulk-#{i}",
created_at: Time.current,
updated_at: Time.current
}
end
assert_difference -> { @room.reload.messages_count }, +3 do
Message.insert_all!(rows)
end
assert_equal @room.messages.count, @room.reload.messages_count
assert_difference -> { @room.reload.messages_count }, -3 do
@room.messages.where(client_message_id: rows.map { it[:client_message_id] }).delete_all
end
assert_equal @room.messages.count, @room.reload.messages_count
end
test "rolled back writes leave the counter unchanged" do
before = @room.reload.messages_count
Message.transaction do
@room.messages.create!(creator: users(:jason), body: "Nope", client_message_id: "count-rollback")
raise ActiveRecord::Rollback
end
assert_equal before, @room.reload.messages_count
assert_nil Message.find_by(client_message_id: "count-rollback")
end
test "moving a message between rooms moves the counter" do
message = @room.messages.create!(creator: users(:jason), body: "Move me", client_message_id: "count-move")
assert_difference -> { @room.reload.messages_count }, -1 do
assert_difference -> { @other_room.reload.messages_count }, +1 do
message.update!(room: @other_room)
end
end
assert_equal @room.messages.count, @room.reload.messages_count
assert_equal @other_room.messages.count, @other_room.reload.messages_count
end
test "Message does not declare an ActiveRecord counter_cache" do
reflection = Message.reflect_on_association(:room)
assert_not reflection.counter_cache_column
end
end
-40
View File
@@ -1,40 +0,0 @@
require "test_helper"
class User::TransferableTest < ActiveSupport::TestCase
test "a transfer id finds its user" do
assert_equal users(:david), User.find_by_transfer_id(users(:david).transfer_id)
end
test "a transfer id expires" do
transfer_id = users(:david).transfer_id
travel User::TRANSFER_LINK_EXPIRY_DURATION + 1.second
assert_nil User.find_by_transfer_id(transfer_id)
end
test "a transfer id made before a password change stops working" do
transfer_id = users(:david).transfer_id
users(:david).update!(password: "a new secret123456")
assert_nil User.find_by_transfer_id(transfer_id)
assert_equal users(:david), User.find_by_transfer_id(users(:david).transfer_id)
end
test "other changes keep a transfer id working" do
transfer_id = users(:david).transfer_id
users(:david).update!(name: "Dave", email_address: "dave@37signals.com")
assert_equal users(:david), User.find_by_transfer_id(transfer_id)
end
test "a transfer id finds only users in scope" do
transfer_id = users(:david).transfer_id
users(:david).deactivate
assert_nil User.active.find_by_transfer_id(transfer_id)
end
end
-64
View File
@@ -42,70 +42,6 @@ class UserTest < ActiveSupport::TestCase
end
end
test "changing the password signs out every session, the current one included" do
Current.session = users(:david).sessions.start!(user_agent: "This device", ip_address: "127.0.0.1")
assert_changes -> { users(:david).sessions.count }, to: 0 do
users(:david).update!(password: "a new secret123456")
end
end
test "changing the password closes the account's live connections" do
remote_connections = mock
remote_connections.expects(:disconnect).with(reconnect: true)
ActionCable.server.stubs(:remote_connections).returns(mock.tap { |m| m.expects(:where).with(current_user: users(:david)).returns(remote_connections) })
users(:david).update!(password: "a new secret123456")
end
test "changing the password closes live connections even when the user is saved again before commit" do
remote_connections = mock
remote_connections.expects(:disconnect).with(reconnect: true)
ActionCable.server.stubs(:remote_connections).returns(mock.tap { |m| m.expects(:where).with(current_user: users(:david)).returns(remote_connections) })
User.transaction do
users(:david).update!(password: "a new secret123456")
users(:david).update!(name: "Dave")
end
end
test "a password change that rolls back keeps every session and connection" do
ActionCable.server.expects(:remote_connections).never
assert_no_changes -> { users(:david).sessions.count } do
User.transaction do
users(:david).update!(password: "a new secret123456")
raise ActiveRecord::Rollback
end
end
end
test "changing the password removes every push subscription of the user" do
assert users(:david).push_subscriptions.any?
users(:david).update!(password: "a new secret123456")
assert_empty users(:david).push_subscriptions.reload
assert users(:jason).push_subscriptions.any?
end
test "a password change that rolls back keeps push subscriptions" do
assert_no_changes -> { users(:david).push_subscriptions.count } do
User.transaction do
users(:david).update!(password: "a new secret123456")
raise ActiveRecord::Rollback
end
end
end
test "changing other details keeps every session" do
ActionCable.server.expects(:remote_connections).never
assert_no_changes -> { [ users(:david).sessions.count, users(:david).push_subscriptions.count ] } do
users(:david).update!(name: "Dave", email_address: "dave@37signals.com")
end
end
private
def create_new_user
User.create!(name: "User", email_address: "user@example.com", password: "secret123456")
-21
View File
@@ -1,21 +0,0 @@
require "test_helper"
class ResetPasswordScriptTest < ActiveSupport::TestCase
test "resets a password without the current one and signs out every session" do
assert_output "Password has been reset\n" do
run_script "david@37signals.com", "a new secret123456"
end
assert users(:david).reload.authenticate("a new secret123456")
assert_empty users(:david).sessions
end
private
def run_script(*arguments)
original_arguments = ARGV.dup
ARGV.replace(arguments)
load Rails.root.join("script/admin/reset-password").to_s
ensure
ARGV.replace(original_arguments)
end
end
-14
View File
@@ -174,20 +174,6 @@ class ComposerTest < ApplicationSystemTestCase
assert_selector last_message_selector("cite"), text: "JZ"
end
test "replying quotes a mention's text as text" do
message = rooms(:designers).messages.create! creator: users(:kevin), client_message_id: "reply-mention",
body: %(<div><span class="mention">&lt;em&gt;Kevin&lt;/em&gt;</span> says hi</div>)
join_room rooms(:designers)
within_message message do
reveal_message_actions
find("[aria-label='Reply']").click
end
assert_composer_text "<em>Kevin</em> says hi"
assert_no_selector "#composer lexxy-editor em"
end
test "arrow up edits my last message when the composer is empty" do
composer_editor.click
press_in_composer :up
@@ -1,74 +0,0 @@
require "application_system_test_case"
class PushSubscriptionResyncTest < ApplicationSystemTestCase
ENDPOINT = "https://fcm.googleapis.com/fcm/send/system-test"
setup do
stub_web_push_dns_resolution
fake_a_browser_push_subscription
end
teardown do
page.driver.browser.execute_cdp "Page.removeScriptToEvaluateOnNewDocument", identifier: @fake_subscription_script
page.execute_script("localStorage.clear()") if page.current_url.start_with?("http")
end
test "a browser posts its subscription again once per session, so it survives a password change" do
sign_in "david@37signals.com"
assert_subscription_registered
first_sync = subscription.updated_at
join_room rooms(:hq)
assert_equal first_sync, subscription.reload.updated_at, "posted again in the same session"
first_id = subscription.id
change_password
assert_subscription_registered
assert_not_equal first_id, subscription.id, "not removed and posted again by the profile page"
assert_not page.evaluate_script("localStorage.getItem('unsubscribed-for-test')")
end
test "a subscription the server refuses stays in the browser" do
stub_dns_resolution("169.254.169.254")
sign_in "david@37signals.com"
Timeout.timeout(5) { sleep 0.1 until page.evaluate_script("localStorage.getItem('push-subscription-synced-session')") }
join_room rooms(:hq)
assert_not Push::Subscription.exists?(endpoint: ENDPOINT)
assert_not page.evaluate_script("localStorage.getItem('unsubscribed-for-test')")
end
private
# Headless Chrome has no push service, so stand in for the subscription the service worker would hold. The
# browser is shared with other system tests, so teardown removes this again.
def fake_a_browser_push_subscription
@fake_subscription_script = page.driver.browser.execute_cdp("Page.addScriptToEvaluateOnNewDocument", source: <<~JS)["identifier"]
(() => {
Object.defineProperty(Notification, "permission", { get: () => "granted" })
const subscription = {
toJSON: () => ({ endpoint: "#{ENDPOINT}", keys: { p256dh: "p256dh-key", auth: "auth-key" } }),
unsubscribe: async () => { localStorage.setItem("unsubscribed-for-test", "true") }
}
navigator.serviceWorker.getRegistration = async () => ({ pushManager: { getSubscription: async () => subscription } })
})()
JS
end
def change_password
visit user_profile_url
find("input[name='user[password_challenge]']").fill_in with: "secret123456"
find("input[name='user[password]']").fill_in with: "a new secret123456"
find("input[name='user[password]']").send_keys :enter
assert_selector ".flash", text: "✓"
end
def assert_subscription_registered
assert_selector "[data-controller~=push-subscription-sync]", visible: :all
Timeout.timeout(5) { sleep 0.1 until Push::Subscription.exists?(endpoint: ENDPOINT, user: users(:david)) }
end
def subscription
Push::Subscription.find_by!(endpoint: ENDPOINT)
end
end
+9
View File
@@ -33,4 +33,13 @@ class ActiveSupport::TestCase
teardown do
WebMock.reset!
end
# fixtures :all inserts messages before rooms, so INSERT triggers cannot count
# yet. Backfill once after load; ensure! covers schema.rb (triggers not dumped).
def load_fixtures(config)
fixtures = super
Room::MessagesCount.ensure!
Room::MessagesCount.backfill!
fixtures
end
end
+2
View File
@@ -22,6 +22,8 @@ module SystemTestHelper
def send_message(message)
fill_in_rich_text_area "message_body", with: message
click_on "send"
# Keep job helpers active through the actual response, not the optimistic DOM.
assert_selector ".message[data-message-id]:not([data-pending-message]) .message__body", text: message
end
def within_message(message, &block)