Compare commits

..
Author SHA1 Message Date
Jeremy Daer 2021bc3296 Merge pull request #373 from basecamp/backport/password-check-1.5.4
Check the current password whenever the saved password changes (1.5.4)
2026-10-19 16:04:57 -07:00
Jeremy Daer 56e2a287ea Check the current password whenever the saved password changes
The profile asked for the current password when the request's password
field was filled in. The check now follows the save itself: the model
checks the challenge whenever the password digest is about to change,
and the profile always hands it the challenge. Editing a name, avatar
or email without a new password still needs none, and scripts that
reset a password, which set no challenge, are unaffected.

Only a string counts as a challenge, and the rate limit counts every
non-empty one, so what's limited is exactly what gets checked.

(cherry picked from commit 4a580302a5)
2026-10-19 15:12:25 -07:00
Jeremy Daer 6c5ceca6b5 Merge pull request #367 from basecamp/backport/security-1.5.3
Security fixes for 1.5.3
2026-10-19 14:08:49 -07:00
Jeremy Daer bf2b9cc61b Look the endpoint up past the query cache, and keep a subscription a race refused
The lookup under the write lock repeated the one made before
validation, so the query cache could answer it with that earlier miss
even after another connection had registered the endpoint. It runs
uncached now.

Enabling notifications unsubscribed the browser on any refusal, so a
session signed out between authenticating and saving lost the browser's
subscription too, and the next session had nothing to post. Only a
refused endpoint unsubscribes it now.

From #371.

(cherry picked from commit aa93269ceb)
2026-10-19 13:34:37 -07:00
Jeremy Daer fadf6f1859 Post the browser's push subscription from every page, and register it once
Only room pages posted the subscription again, so a browser that
changed its password and closed the profile page went without
notifications until it next opened a room. A small controller in the
layout does it now, still once per session, and the bell is back to how
it was.

Two tabs posting at once after a password change could both miss the
row and insert two, so the endpoint is looked up again under the same
write lock as the session check.

From #371.

(cherry picked from commit 3786ee5244)
2026-10-19 13:15:14 -07:00
Jeremy Daer 931f6b2c38 Remove the fake push subscription after its system test
The browser is shared across system tests, and a script added to every
new document stays until it's removed, so later tests would have seen
the granted permission and the fake subscription too.

From #371.

(cherry picked from commit 1af62a5bff)
2026-10-19 12:55:16 -07:00
Jeremy Daer 93718f7408 Remove push subscriptions on a password change, and post them again
A push subscription belongs to the user, not a session, so a device
signed out by a password change kept getting notifications. Changing
the password now removes every subscription along with the sessions,
in the same transaction.

A browser still signed in keeps its own subscription in its service
worker, so the notifications controller posts it once per session: the
bell names the session by a digest of its id, never its token, and the
browser remembers the last one it posted for. That post never
unsubscribes the browser when the server refuses it.

A request that authenticated just before the change could put a
subscription back after it. The create action now checks the session
still exists and saves in one immediate transaction, after validating
the endpoint outside it.

From #371.

(cherry picked from commit 6cab924097)
2026-10-19 12:39:14 -07:00
Jeremy Daer 417f087964 Ask for the current password to change it
Changing the password now ends every other session, so whoever holds
one could shut the owner out of their own account with a new password.
The profile asks for the current password whenever the password
changes; without it, or with a wrong one, nothing is saved and the page
says why. Other profile edits don't need it, and the admin
reset-password script still works without it.

Attempts are rate limited like signing in, 10 in 3 minutes per address,
so the current password can't be guessed here instead.

From #371. The cached-response test it touches isn't in 1.5.2.

(cherry picked from commit 44a92ff6c6)
2026-10-19 12:17:27 -07:00
Jeremy Daer 58a25c6281 End sign-in links made before a password change
A transfer link signed only the user id, so one copied before the
password changed still started a session for up to four hours after.
Bind it to the password salt with generates_token_for, so a new
password ends those links too. Links made before this upgrade stop
working; they last four hours at most.

From #371.

(cherry picked from commit 160ba295b5)
2026-10-19 11:37:05 -07:00
Jeremy Daer 00abff4088 Close live connections on the transaction that changed the password
An after_commit condition reads the record's last save, so another save
of the user in the same transaction skipped the disconnect even though
the sessions were gone. The callback that deletes them now registers
the disconnect on the transaction itself, which also leaves a rolled
back change alone. A password changed along with the avatar is covered
too: the attachment's touch doesn't hide the change.

From #371.

(cherry picked from commit e158388f3d)
2026-10-19 10:57:49 -07:00
Jeremy Daer b1122c7d6d Give the changing browser a new session rather than keeping its own
Keeping the session the password was changed from also kept any copy of
its cookie working, and a copied cookie is one of the ways someone else
gets into an account. Every session ends now, and the profile page
signs the browser that changed the password back in with a new one.

From #371.

(cherry picked from commit b01bb7a0a7)
2026-10-19 10:46:24 -07:00
Jeremy Daer 2fb599e21c Close live connections from the model on any password change
The sessions end wherever the password changes, but only the profile
page closed the live connections, so a change from a console left a
signed-out device receiving messages until it dropped. Close them after
commit from User, logging rather than failing if the realtime service
can't be reached, since the password is saved by then.

From #371.

(cherry picked from commit c6e0105491)
2026-10-19 10:33:48 -07:00
Jeremy Daer c78f03fda7 Sign out the account's other sessions when its password changes
Changing the password is how someone shuts out whoever else is signed
in to their account, but every session opened with the old password
stayed valid. Now the others end and their live connections close, as
on sign-out. The session the password was changed from stays signed in.

Changing the password outside a request, as from a console, ends every
session.

From #371. 1.5.2 has no record snapshot or response cache, so the test
that a cached session lookup doesn't outlive the password change is
left out; the rest applies as it is.

(cherry picked from commit c78c3fd7ef)
2026-10-19 10:28:55 -07:00
Rosa Gutierrez 9474e70f5a Clear no_proxy in the link preview proxy test
(cherry picked from commit 2d4b05a74a)
2026-10-17 01:11:38 -07:00
Rosa Gutierrez a1dd6e1d8a Don't let an http_proxy environment variable bypass the pinned address in link previews
Opengraph::Fetch connects to the IP it checked, not to a fresh lookup of
the host. With http_proxy set, Net::HTTP would instead send the hostname
to the proxy, which looks it up again, so the connection could end up
somewhere other than the checked address. Pass nil as the proxy address,
as web push delivery already does.

(cherry picked from commit a7e9ea53dd)
2026-10-17 01:11:38 -07:00
Jeremy Daer d3e6602b88 Run CI on pull requests to release branches
Patch releases are prepared on a release-* branch cut from their tag,
and CI only ran on pull requests to main. Pushes to a release branch
stay untriggered, as zizmor treats a push trigger on release branches as
a publishing workflow whose setup-ruby cache could be poisoned.
2026-10-17 00:48:24 -07:00
Jeremy Daer 7a5931fe2a Allow an asset host only up to its own asset directory
An asset host's path can contain the asset directory too, as in
https://cdn.example.com/assets, and cutting each URL at the first one would
allow everything the host serves under it. Cut at the last one instead.

(cherry picked from commit 2ba940c683)
2026-10-17 00:47:41 -07:00
Jeremy Daer 26b3d2abb0 Leave the scheme off this origin's script sources
Behind a proxy that terminates TLS without X-Forwarded-Proto, Rails sees
http while the page is https, and an http source on a non-default port
doesn't match. A source without a scheme takes the page's own.

(cherry picked from commit 0a494a10cb)
2026-10-17 00:47:41 -07:00
Jeremy Daer 681bd78ea2 Allow scripts only from the asset directory, not the whole origin
Uploads are served from this origin too, so script-src 'self' would run one
that a cache still holds under a script type from before the upload guard.
Application pages now allow each importmap URL up to its asset directory
(relative root and asset host included), the inline importmap by hash, and
the service worker by worker-src.

(cherry picked from commit 2be2227f40)
2026-10-17 00:47:41 -07:00
Jeremy Daer 8f249cbde4 Leave a request without a policy when the policy is turned off
With DISABLE_CONTENT_SECURITY_POLICY=true there's no application policy,
and the hash callback started an empty one, so pages sent an empty
Content-Security-Policy header. Skip the callback then.

(cherry picked from commit 2a449401ec)
2026-10-17 00:47:32 -07:00
Jeremy Daer 27f7b8a9b0 Keep a multi-range upload response's multipart type
A request for several byte ranges gets a 206 multipart/byteranges whose
boundary the client needs to split the body. Rewriting it to
octet-stream lost the boundary. No browser runs that type as a script,
so it passes through.

(cherry picked from commit 9c37f41ff6)
2026-10-17 00:47:32 -07:00
Jeremy Daer 05ba551cef Say how long a pre-upgrade cached upload can last
Disk URLs are cached for an hour, but blob proxy URLs are cached
publicly and as immutable for a hundred years, so a copy taken that way
before the upgrade doesn't expire.

(cherry picked from commit d713f2631d)
2026-10-17 00:47:32 -07:00
Jeremy Daer c3616f81b4 Enforce a script policy that allows the importmap by hash
script-src 'self', object-src 'none' and base-uri 'self', enforced. The
two inline scripts the importmap helper emits are allowed by their
SHA-256, and the origins its URLs point to (a configured asset host, in
any form Rails accepts) are allowed by name. There is no per-request
nonce, so cached pages stay valid.

DISABLE_CONTENT_SECURITY_POLICY=true leaves the policy off, for an
install whose proxy or customization injects scripts.

(cherry picked from commit 562888251e)

Backport: 1.5.2 has no response cache, so the cached-page test is left out.
2026-10-17 00:47:32 -07:00
Jeremy Daer 3a140f65b2 Serve uploads that aren't media as downloads
Uploaded files come from this origin, so one served with a JavaScript
type could run as one of the app's own scripts. Browsers accept sixteen
spellings of that type, and Disk URLs sign the type they were minted
with, so refusing a list of types at upload can't cover links already
out there. Instead, a middleware rewrites the type of anything Active
Storage reads out that isn't plainly media, a PDF or plain text to
application/octet-stream, whatever the URL says.

(cherry picked from commit cad2d7211d)
2026-10-17 00:47:22 -07:00
Jeremy Daer 049fc920d3 Quote a mention as text when replying
Replying swaps each mention in the quoted copy for its text. Assigning
that text to outerHTML parsed it as html; replace the mention with a
text node instead.

(cherry picked from commit 6052b123d5)
2026-10-17 00:46:37 -07:00
Jeremy Daer 6ba5d2ece3 Link URLs only in a message's text
auto_link finds URLs and email addresses with regular expressions over
the rendered html, which can't tell an attribute value from text, so it
could put a link inside an attribute. Hand it each text node on its own,
escaped the way the serializer writes text, after the final sanitize, so
links only ever land between tags. Write the result with
preserve_newline so a code block keeps its leading blank lines.

Bump the presentation cache version so nothing rendered the old way is
served again.

Backport of 7ea05e1, 4e29c6e and 9b271d0 from #364. 1.5.2 has no
plain-text fragment cache, so only the message partial's version moves.
2026-10-17 00:46:37 -07:00
Jeremy Daer a67ecde390 Compare a table message's filtered html as markup
The filters can write the same table with or without newlines between
its rows depending on which tests ran first in the process, so the exact
string comparison failed under some seeds. main compares it as markup.
2026-10-17 00:46:37 -07:00
Jeremy Daer e4ef1785e8 Update Brakeman to 8.1.0
(cherry picked from commit b2140cf2aa)
2026-10-17 00:37:47 -07:00
92 changed files with 124 additions and 2816 deletions

No files matched your search

+7 -7
View File
@@ -4,7 +4,7 @@ on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
branches: [ main, "release-*" ]
permissions: {}
@@ -21,7 +21,7 @@ jobs:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@26e5b7d197ebf60545ee9667bcbace0be11ccaec # v1.334.0
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
with:
ruby-version: .ruby-version
bundler-cache: true
@@ -40,7 +40,7 @@ jobs:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@26e5b7d197ebf60545ee9667bcbace0be11ccaec # v1.334.0
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
with:
ruby-version: .ruby-version
bundler-cache: true
@@ -63,7 +63,7 @@ jobs:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@26e5b7d197ebf60545ee9667bcbace0be11ccaec # v1.334.0
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
with:
ruby-version: .ruby-version
bundler-cache: true
@@ -86,7 +86,7 @@ jobs:
uses: rhysd/actionlint@914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12
- name: Run zizmor
uses: zizmorcore/zizmor-action@85b2b9fb8e3325e7a23d7d0a9d7ba5ae0f999e3e # v0.7.1
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
with:
advanced-security: false
@@ -110,7 +110,7 @@ jobs:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@26e5b7d197ebf60545ee9667bcbace0be11ccaec # v1.334.0
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
env:
REDIS_URL: redis://localhost:6379/0
with:
@@ -143,7 +143,7 @@ jobs:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@26e5b7d197ebf60545ee9667bcbace0be11ccaec # v1.334.0
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
env:
REDIS_URL: redis://localhost:6379/0
with:
+1 -1
View File
@@ -1 +1 @@
4.0.9
3.4.10
+1 -1
View File
@@ -1,7 +1,7 @@
# syntax = docker/dockerfile:1
# Make sure it matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=4.0.9
ARG RUBY_VERSION=3.4.10
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
+1 -1
View File
@@ -1,7 +1,7 @@
# syntax = docker/dockerfile:1
# Make sure it matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=4.0.9
ARG RUBY_VERSION=3.4.10
FROM ruby:$RUBY_VERSION-slim as base
# Install the tools we need
+1 -1
View File
@@ -12,7 +12,7 @@ gem "sqlite3"
gem "redis", "~> 5.4"
# Deployment
gem "puma", "~> 8.0"
gem "puma", "~> 7.2", ">= 7.2.1"
# Jobs
gem "resque", "~> 2.7.0"
+13 -13
View File
@@ -276,7 +276,7 @@ GEM
date
stringio
public_suffix (6.0.2)
puma (8.0.2)
puma (7.2.1)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.7)
@@ -336,29 +336,29 @@ GEM
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.1.0)
rubocop (1.91.0)
json (>= 2.3)
rubocop (1.80.2)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (>= 1.10)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.49.0, < 2.0)
rubocop-ast (>= 1.46.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.50.0)
rubocop-ast (1.46.0)
parser (>= 3.3.7.2)
prism (~> 1.7)
rubocop-performance (1.27.0)
prism (~> 1.4)
rubocop-performance (1.25.0)
lint_roller (~> 1.1)
rubocop (>= 1.89.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.38.0)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails (2.33.3)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.89.0, < 2.0)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rails-omakase (1.1.0)
rubocop (>= 1.72)
@@ -452,7 +452,7 @@ DEPENDENCIES
ostruct
platform_agent
propshaft!
puma (~> 8.0)
puma (~> 7.2, >= 7.2.1)
rails!
rails_autolink
redis (~> 5.4)
+5 -18
View File
@@ -49,26 +49,17 @@ 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 complete cached HTML and gzip bodies.
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 | 4,101 | 1,507 | 3,872 | 42,636 | 5,350 | 53,060 | 106,494 | 137,524 |
| Messages page | 4,115 | 1,596 | 3,995 | 74,362 | 5,712 | 54,800 | 102,697 | 144,642 |
| Sidebar | 4,333 | 1,873 | 4,493 | 94,329 | 5,949 | 59,144 | 120,294 | 152,002 |
| Search | 4,282 | 1,862 | 4,172 | 84,665 | 5,848 | 60,509 | 121,378 | 149,487 |
| Post a message | 330 | 262 | 794 | 2,155 | 1,278 | 9,021 | 8,037 | 7,530 |
| Backend KLOC | 5.5 | 5.3 | 4.1 | 6.8 | 11.6 | 23.2 | 30.7 | 94.8 |
KLOC counts backend code plus executable code in templates; excludes plain HTML, frontend, tests, dependencies and generated files.
| 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 |
[Shared verification](https://github.com/basecamp/once-campfire-verification) · [Detailed results](https://github.com/basecamp/once-campfire-verification/blob/main/docs/performance-review.md).
@@ -83,7 +74,3 @@ Please see our [development guide](docs/development.md) for how to get Campfire
## Security
See [SECURITY.md](SECURITY.md) for how to report a vulnerability and a description of our trust model.
Browser writes use Rails’ `Sec-Fetch-Site` header-only protection and `Origin` check.
HTTPS requires browser metadata; plain HTTP retains the missing-header fallback.
Forms contain no CSRF tokens. Existing cookies, bot APIs and signed uploads remain compatible.
+2 -25
View File
@@ -2,42 +2,19 @@ module ApplicationCable
class Connection < ActionCable::Connection::Base
include Authentication::SessionLookup
SESSION_CHECK_INTERVAL = 1.minute
identified_by :current_user
def connect
self.current_user = find_verified_user
@session_checked_at = Time.now
end
# Signing out or changing the password ends sessions and then disconnects their connections, but a connection can
# miss that: one that found its session just before isn't listening yet. So on the server's heartbeat, which
# reaches every open connection, each one looks its session up again now and then, and closes once it's gone. A
# rejected connection has no session, and stays registered until its socket closes.
def beat
super
check_session if @session_checked_at&.before?(SESSION_CHECK_INTERVAL.ago)
end
private
def find_verified_user
if @verified_session = find_session_by_cookie
@verified_session.user
if verified_session = find_session_by_cookie
verified_session.user
else
reject_unauthorized_connection
end
end
def check_session
@session_checked_at = Time.now
perform_work -> { close_unless_session_remains }, :call
end
def close_unless_session_remains
unless Session.exists?(@verified_session.id)
close reason: ActionCable::INTERNAL[:disconnect_reasons][:unauthorized]
end
end
end
end
+1 -1
View File
@@ -1,4 +1,4 @@
class ApplicationController < ActionController::Base
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders, CachedResponses, InlineScriptHashes
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders, InlineScriptHashes
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
end
+2 -2
View File
@@ -7,7 +7,7 @@ module Authentication
before_action :deny_bots
helper_method :signed_in?
protect_from_forgery using: :header_only, with: :exception, unless: -> { authenticated_by.bot_key? }
protect_from_forgery with: :exception, unless: -> { authenticated_by.bot_key? }
end
class_methods do
@@ -57,7 +57,7 @@ module Authentication
end
def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
user.sessions.start!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
authenticated_as session
end
end
@@ -1,12 +1,7 @@
module Authentication::SessionLookup
def find_session_by_cookie
if token = cookies.signed[:session_token]
version = @response_cache_version if @response_cache_version && (request.get? || request.head?)
session, user = RecordCache.fetch([ "session", Digest::SHA256.hexdigest(token.to_s) ], version) do
session = Session.find_by(token: token)
[ session, session&.user ]
end
session&.tap { |record| record.user = user }
Session.find_by(token: token)
end
end
end
@@ -1,118 +0,0 @@
require "zlib"
module CachedResponses
extend ActiveSupport::Concern
CACHE_HEADERS = %w[ content-type content-encoding cache-control etag last-modified vary ].freeze
included do
prepend_before_action :capture_response_cache_version
end
def perform_caching
return false unless super && @response_cache_version.present? && !FragmentCache.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_context ||= [
request.base_url, request.script_name, request.format.to_s, I18n.locale,
Current.user&.id, (Digest::SHA256.hexdigest(Current.session.token) if Current.session)
].freeze
version = @response_cache_version unless Array(key).flatten.any? { |part| part.is_a?(FragmentCache::ContentKey) }
super([ version, @fragment_cache_context, key ])
end
private
def read_record_cache_version
@response_cache_version if request.get? || request.head?
end
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?
encoding = Rack::Utils.select_best_encoding(%w[ gzip identity ], Rack::Utils.q_values(request.headers["Accept-Encoding"]))
return yield unless encoding
key = response_cache_key(encoding)
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
original_session = session.to_hash.deep_dup
yield
rendered = true
entry = cache_completed_response(key, original_session, encoding)
end
end
end
if entry
response.headers.merge!(entry[:headers])
response.headers.delete("Content-Length")
self.response_body = entry[:body]
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, encoding)
if response.status == 200 && response.media_type == "text/html" && session.to_hash == original_session && !response.headers["Content-Encoding"]
body = encoding == "gzip" ? Zlib.gzip(response.body) : response.body
unless body.empty? || response.headers["ETag"] || response.headers["Last-Modified"]
# Match Rack::ETag once, rather than hashing the same bytes on each hit.
response.headers["ETag"] = %(W/"#{Digest::SHA256.hexdigest(body).byteslice(0, 32)}")
end
response.headers["Content-Encoding"] = "gzip" if encoding == "gzip"
response.headers["Vary"] = (response.headers["Vary"].to_s.split(/,\s*/) | [ "Accept-Encoding" ]).join(", ")
headers = response.headers.slice(*CACHE_HEADERS).to_h.freeze
entry = { body: body.freeze, headers: headers }.freeze
ResponseCache.instance.write(key, @response_cache_version, entry)
entry
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 &&
!FragmentCache.transaction_open?
end
def response_cache_key(encoding)
ActiveSupport::JSON.encode([
controller_path, request.fullpath, request.base_url, request.user_agent, encoding,
request.headers["Accept"], request.headers["Turbo-Frame"], I18n.locale,
# Old token-bearing installation cookies remain valid without affecting HTML.
Current.user.id, Current.session.token, session.to_hash.except("_csrf_token"),
cookies.to_h.except("_campfire_session", "session_token")
])
end
end
@@ -14,32 +14,13 @@ module InlineScriptHashes
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_sources(importmap)
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
mattr_accessor :last_script_sources
private
# Parsing the importmap and resolving each of its URLs costs about a
# millisecond a page, yet the answer changes only with the importmap and
# the host the page is served from. Keep the last one: a single entry, so
# a request with a made-up Host header can't grow it.
def script_sources(importmap)
key = [ importmap, request.base_url, request.host_with_port, config.relative_url_root ]
cached_key, sources = InlineScriptHashes.last_script_sources
if cached_key == key
sources
else
(script_locations(importmap) | script_hashes(importmap, ENTRY_POINT_SCRIPT)).freeze.tap do |sources|
InlineScriptHashes.last_script_sources = [ key, sources ].freeze
end
end
end
# 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
+2 -5
View File
@@ -7,10 +7,7 @@ module RoomScoped
private
def set_room
@membership, @room = RecordCache.fetch([ "membership", Current.user.id, params[:room_id] ], read_record_cache_version) do
membership = Current.user.memberships.find_by!(room_id: params[:room_id])
[ membership, membership.room ]
end
@membership.room = @room
@membership = Current.user.memberships.find_by!(room_id: params[:room_id])
@room = @membership.room
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")
+2 -11
View File
@@ -4,7 +4,6 @@ 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
@@ -12,13 +11,7 @@ class MessagesController < ApplicationController
@messages = find_paged_messages
if @messages.any?
body = render_to_string(:index)
# Creator, body and boost edits can change HTML without touching messages.
fresh_when etag: Digest::SHA256.hexdigest(body), template: false
unless performed?
response.content_type = "text/html"
self.response_body = body
end
fresh_when @messages
else
head :no_content
end
@@ -28,9 +21,7 @@ class MessagesController < ApplicationController
set_room
@message = @room.messages.create_with_attachment!(message_params)
# Both deliveries contain the same token-free, viewer-independent markup.
@message_html = render_to_string partial: "messages/message", formats: :html, locals: { message: @message }
@message.broadcast_create(html: @message_html)
@message.broadcast_create
deliver_webhooks_to_bots
rescue ActiveRecord::RecordNotFound
render action: :room_not_found
-2
View File
@@ -7,7 +7,5 @@ class QrCodeController < ApplicationController
expires_in 1.year, public: true
render plain: qr_code, content_type: "image/svg+xml"
rescue ArgumentError, RQRCodeCore::QRCodeRunTimeError
head :bad_request
end
end
+1 -5
View File
@@ -2,7 +2,6 @@ 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)
@@ -21,10 +20,7 @@ class RoomsController < ApplicationController
private
def set_room
room = RecordCache.fetch([ "room", self.class.name, Current.user.id, params[:room_id] || params[:id] ], read_record_cache_version) do
[ room_scope.find_by(id: params[:room_id] || params[:id]) ]
end.first
if room
if room = room_scope.find_by(id: params[:room_id] || params[:id])
@room = room
else
redirect_to root_url, alert: "Room not found or inaccessible"
-1
View File
@@ -1,5 +1,4 @@
class SearchesController < ApplicationController
around_action :cache_read_response, only: :index
before_action :set_messages
def index
@@ -11,7 +11,5 @@ class Sessions::TransfersController < ApplicationController
else
head :bad_request
end
rescue ActiveRecord::RecordInvalid
head :bad_request
end
end
-2
View File
@@ -14,8 +14,6 @@ class SessionsController < ApplicationController
else
render_rejection :unauthorized
end
rescue ActiveRecord::RecordInvalid
render_rejection :unauthorized
end
def destroy
@@ -18,9 +18,6 @@ class Users::ProfilesController < ApplicationController
else
redirect_to user_profile_url, alert: update_alert
end
rescue ActiveRecord::RecordInvalid
# The password changed again before this browser's new session could start, and that change signed it out.
redirect_to new_session_url
end
private
@@ -1,6 +1,5 @@
class Users::SidebarsController < ApplicationController
DIRECT_PLACEHOLDERS = 20
around_action :cache_read_response, only: :show
def show
visible_memberships = Current.user.memberships.visible
-5
View File
@@ -36,11 +36,6 @@ module ApplicationHelper
end
private
# Header-only forgery protection needs no secret in forms or cached HTML.
def token_tag(*)
""
end
def admin_body_class
"admin" if Current.user&.can_administer?
end
+1 -41
View File
@@ -66,7 +66,7 @@ module MessagesHelper
when "sound"
message_sound_presentation(message)
else
text_message_presentation(message.body.body)
auto_link_text h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body))
end
rescue Exception => e
Sentry.capture_exception(e, extra: { message: message })
@@ -75,47 +75,7 @@ module MessagesHelper
""
end
# These controls contain URLs and static markup, but no viewer or token state.
# Keep them across database commits; attachment controls still render afresh.
def cache_message_actions(message, &block)
return capture(&block) unless controller.perform_caching && !message.attachment?
key = fragment_name_with_digest([
"message-actions-v1", request.base_url, request.script_name, I18n.locale, message.id, message.room_id
], nil)
FragmentCache.store.fetch(key) { capture(&block) }
end
def message_fragment_cache_key(message)
return message unless controller.perform_caching &&
%i[room creator rich_text_body boosts attachment_attachment].all? { |name| message.association(name).loaded? }
room = message.room
body = message.body.body
return message if room.direct? || message.attachment? || !body || body.to_html.include?("<action-text-attachment") ||
message.boosts.any? { |boost| !boost.association(:booster).loaded? }
# Collection caching preloads these rows before deriving keys. Read their
# actual content: foreign SQL writers need not touch Rails timestamps.
dependencies = [ message.attributes, room.attributes.slice("id", "type", "name"), message.creator.attributes,
body.to_html, message.boosts.sort_by(&:created_at).map { |boost| [ boost.attributes, boost.booster.attributes ] } ]
FragmentCache::ContentKey.new(Digest::SHA256.hexdigest(ActiveSupport::JSON.encode(dependencies)))
end
private
def text_message_presentation(body)
render = -> { auto_link_text h(ContentFilters::TextMessagePresentationFilters.apply(body)) }
# Embedded attachments render database-backed metadata and signed URLs.
# Plain text HTML depends only on its content, even after a foreign edit.
html = body.to_html
if controller.perform_caching && !html.include?("<action-text-attachment") && html.bytesize <= ResponseCache::MAX_ENTRY_BYTES
FragmentCache.store.fetch([ "text-presentation-v2", I18n.locale, Digest::SHA256.hexdigest(html) ]) { render.call }
else
render.call
end
end
# 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.
+1
View File
@@ -13,6 +13,7 @@ export default class FileUploader {
const req = new XMLHttpRequest()
req.open("POST", this.url)
req.setRequestHeader("X-CSRF-Token", document.querySelector("meta[name=csrf-token]").content)
req.upload.addEventListener("progress", this.#uploadProgress.bind(this))
const result = new Promise((resolve, reject) => {
-20
View File
@@ -1,20 +0,0 @@
# Native view caches share one byte budget across every database generation.
class FragmentCache
# Only callers that fingerprint every rendered dependency may omit the epoch.
ContentKey = Data.define(:digest) do
def cache_key
digest
end
end
STORE = ActiveSupport::Cache::MemoryStore.new(size: 64.megabytes)
private_constant :STORE
def self.store
STORE
end
def self.transaction_open?
ActiveRecord::Base.connection_pool.with_connection(&:transaction_open?)
end
end
+2 -18
View File
@@ -9,9 +9,7 @@ class Message < ApplicationRecord
has_rich_text :body
before_create -> { self.client_message_id ||= Random.uuid } # Bots don't care
# Run after Action Text and Active Storage autosave, while creation is still atomic.
after_save :record_creation, if: :previously_new_record?
after_create_commit -> { room.push_later(self) }
after_create_commit -> { room.receive(self) }
scope :ordered, -> { order(:created_at) }
scope :with_creator, -> { preload(creator: :avatar_attachment) }
@@ -23,15 +21,7 @@ class Message < ApplicationRecord
scope :with_presentation, -> { with_creator.with_attachment_details.with_boosts.preload(:room) }
def plain_text_body
content = body.body
text = if content && content.fragment.find_all(ActionText::Attachment.tag_name).empty?
# ActionText::Content#to_plain_text duplicates the fragment to replace
# attachments. Without them, reuse its native Fragment conversion instead.
content.fragment.to_plain_text.dup
else
body.to_plain_text
end
text.presence || attachment&.filename&.to_s || ""
body.to_plain_text.presence || attachment&.filename&.to_s || ""
end
def content_type
@@ -47,10 +37,4 @@ class Message < ApplicationRecord
Sound.find_by_name match[:name]
end
end
private
def record_creation
create_in_index
room.unread_memberships(self)
end
end
+2 -2
View File
@@ -1,6 +1,6 @@
module Message::Broadcasts
def broadcast_create(html: nil)
broadcast_append_to room, :messages, target: [ room, :messages ], **(html ? { html: html } : {})
def broadcast_create
broadcast_append_to room, :messages, target: [ room, :messages ]
broadcast_unread_room_later
end
+1 -2
View File
@@ -2,8 +2,7 @@ module Message::Mentionee
extend ActiveSupport::Concern
def mentionees
ids = mentioned_users.map(&:id)
ids.empty? ? User.none : room.users.where(id: ids)
room.users.where(id: mentioned_users.map(&:id))
end
private
+1
View File
@@ -2,6 +2,7 @@ module Message::Searchable
extend ActiveSupport::Concern
included do
after_create_commit :create_in_index
before_update -> { @attachment_replaced = attachment_changes.key?("attachment") }
after_update_commit :update_in_index, if: :indexed_text_changed?
after_destroy_commit :remove_from_index
-24
View File
@@ -1,24 +0,0 @@
# Rebuild request-local models from bounded, immutable database snapshots.
# The observer must still see the captured epoch after lookup and admission.
class RecordCache
def self.fetch(key, version)
return yield unless version && ResponseCache.instance.budget.positive? && !FragmentCache.transaction_open?
key = ActiveSupport::Cache.expand_cache_key([ "record-snapshot-v1", version, key ])
return yield if key.bytesize > ResponseCache::MAX_KEY_BYTES
if snapshot = FragmentCache.store.read(key)
if ResponseCache.instance.version == version
return ActiveSupport::JSON.decode(snapshot).map { |name, attributes| name.constantize.instantiate(attributes) }
end
end
records = yield
if records.all? { |record| record&.persisted? && !record.changed? } && ResponseCache.instance.version == version
# Raw database values retain timestamp precision and native enum binding.
snapshot = ActiveSupport::JSON.encode(records.map { |record| [ record.class.name, record.attributes_before_type_cast ] })
FragmentCache.store.write(key, snapshot) if snapshot.bytesize <= ResponseCache::MAX_ENTRY_BYTES
end
records
end
end
-98
View File
@@ -1,98 +0,0 @@
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
+8 -13
View File
@@ -78,19 +78,6 @@ class Room < ApplicationRecord
push_later(message)
end
# Rewriting every member on every message is most of what posting to a large room writes,
# so members who are unread already stay as they are. Directs keep touching them all: a
# direct's sidebar row is cached by membership and shows the room's recency.
def unread_memberships(message)
recipients = memberships.visible.disconnected.where.not(user: message.creator)
recipients = recipients.where(unread_at: nil) unless direct?
recipients.update_all(unread_at: message.created_at, updated_at: Time.current)
end
def push_later(message)
Room::PushMessageJob.perform_later(self, message)
end
def open?
is_a?(Rooms::Open)
end
@@ -116,4 +103,12 @@ class Room < ApplicationRecord
errors.add :type, "can't be changed for a direct room"
end
end
def unread_memberships(message)
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
end
def push_later(message)
Room::PushMessageJob.perform_later(self, message)
end
end
+16 -4
View File
@@ -6,9 +6,9 @@ class Room::MessagePusher
end
def push
subscriptions = push_subscriptions_for_users_involved_in_everything.or(push_subscriptions_for_mentionable_users(message.mentionees))
if subscriptions.exists?
Rails.configuration.x.web_push_pool.queue(build_payload, subscriptions)
build_payload.tap do |payload|
push_to_users_involved_in_everything(payload)
push_to_users_involved_in_mentions(payload)
end
end
@@ -37,6 +37,14 @@ class Room::MessagePusher
}
end
def push_to_users_involved_in_everything(payload)
enqueue_payload_for_delivery payload, push_subscriptions_for_users_involved_in_everything
end
def push_to_users_involved_in_mentions(payload)
enqueue_payload_for_delivery payload, push_subscriptions_for_mentionable_users(message.mentionees)
end
def push_subscriptions_for_users_involved_in_everything
relevant_subscriptions.merge(Membership.involved_in_everything)
end
@@ -51,6 +59,10 @@ class Room::MessagePusher
Push::Subscription
.joins(user: :memberships)
.merge(User.active)
.merge(Membership.visible.disconnected.where(room: room).where.not(user_id: message.creator_id))
.merge(Membership.visible.disconnected.where(room: room).where.not(user: message.creator))
end
def enqueue_payload_for_delivery(payload, subscriptions)
Rails.configuration.x.web_push_pool.queue(payload, subscriptions)
end
end
-99
View File
@@ -1,99 +0,0 @@
# 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
+3 -12
View File
@@ -7,22 +7,13 @@ class Session < ApplicationRecord
before_create { self.last_active_at ||= Time.now }
validate :user_password_unchanged, on: :create
def self.start!(user_agent:, ip_address:)
create! user_agent: user_agent, ip_address: ip_address
end
def resume(user_agent:, ip_address:)
if last_active_at.before?(ACTIVITY_REFRESH_RATE.ago)
update! user_agent: user_agent, ip_address: ip_address, last_active_at: Time.now
end
end
private
# A password change ends every session, so one can't start on a password checked before the change. Validated in
# the save's transaction, which holds the database's write lock from the start, so a change commits either before
# this check, and fails it, or after the insert, and ends this session with the others. Read past the query cache,
# and compared here rather than in the query, which logs its values.
def user_password_unchanged
unless User.uncached { User.where(id: user_id).pick(:password_digest) } == user.password_digest
errors.add :user, :invalid
end
end
end
+1
View File
@@ -9,6 +9,7 @@
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)">
<meta name="apple-mobile-web-app-capable" content="yes">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= current_user_meta_tags %>
<%= script_aware_action_cable_meta_tag %>
+5 -9
View File
@@ -1,6 +1,5 @@
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
<%= cache_message_actions(message) do %>
<div class="message__actions" data-controller="soft-keyboard">
<%= tag.details class: "position-relative",
data: { controller: "popup", action: "keydown.esc->popup#close toggle->popup#toggle click@document->popup#closeOnClickOutside", popup_orientation_top_class: "popup-orientation-top" } do %>
@@ -11,16 +10,14 @@
<div class="message__actions-menu border shadow" data-popup-target="menu">
<div class="quick-boosts">
<% boost_path = message_boosts_path(message) %>
<% boost_frame = dom_id(message, :boosting) %>
<% EmojiHelper::REACTIONS.each do |character, title| %>
<form data-turbo-frame="<%= boost_frame %>" data-action="popup#close" action="<%= boost_path %>" accept-charset="UTF-8" method="post">
<%= form_with model: [ message, Boost.new ], data: { turbo_frame: dom_id(message, :boosting), action: "popup#close"} do |form| %>
<%= hidden_field_tag "boost[content]", character %>
<button name="button" type="submit" title="<%= title %>" class="btn message__action-btn" data-emoji="<%= character %>">
<%= form.button type: "submit", title: title, class: "btn message__action-btn", data: { emoji: character } do %>
<figure class="margin-none boost-character"><%= character %></figure>
<span class="for-screen-reader"><%= title %></span>
</button>
</form>
<% end %>
<% end %>
<% end %>
<%= link_to new_message_boost_path(message),
@@ -32,7 +29,7 @@
</div>
<div class="flex flex-wrap border-top margin-block-start-half pad-block-start-half message__actions-grid">
<% if message.attachment? %>
<% if message.content_type.attachment? %>
<%= link_to rails_blob_path(message.attachment, disposition: "attachment", only_path: true), class: "btn message__action-btn center full-width hide-in-ios-pwa", title: "Download", aria: { label: "Download" } do %>
<%= image_tag "download.svg", class: "colorize--black", size: 20, aria: { hidden: "true" } %>
<% end %>
@@ -59,4 +56,3 @@
</div>
<% end %>
</div>
<% end %>
+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_fragment_cache_key(message), "presentation-v8" ] do %>
<% cache [ message, "presentation-v7" ] do %>
<%= message_tag message do %>
<h2 class="message__day-separator"><%= local_datetime_tag message.created_at, style: :date %></h2>
+1 -1
View File
@@ -1 +1 @@
<%= turbo_stream.append dom_id(@message.room, :messages), @message_html %>
<%= turbo_stream.append dom_id(@message.room, :messages), @message %>
+1 -1
View File
@@ -1 +1 @@
<%= render partial: "messages/message", collection: @messages, cached: ->(message) { message_fragment_cache_key(message) } %>
<%= render partial: "messages/message", collection: @messages, cached: true %>
@@ -1,5 +1,5 @@
<%= turbo_stream.append dom_id(@room, :messages) do %>
<%= render partial: "messages/message", collection: @new_messages, cached: ->(message) { message_fragment_cache_key(message) } %>
<%= render partial: "messages/message", collection: @new_messages, cached: true %>
<% end if @new_messages.any? %>
<% @updated_messages.each do |message| %>
+1 -1
View File
@@ -14,7 +14,7 @@
<%= messages_tag(@room) do %>
<%= render "rooms/show/invitation", room: @room %>
<%= render partial: "messages/message", collection: @messages, cached: ->(message) { message_fragment_cache_key(message) } %>
<%= render partial: "messages/message", collection: @messages, cached: true %>
<% end %>
<%= turbo_stream_from @room, :messages, channel: "RoomMessagesChannel" %>
+1 -1
View File
@@ -54,7 +54,7 @@
</div>
<%= search_results_tag do %>
<%= render partial: "messages/message", collection: @messages, cached: ->(message) { message_fragment_cache_key(message) } %>
<%= render partial: "messages/message", collection: @messages, cached: true %>
<% end %>
</div>
-5
View File
@@ -9,11 +9,6 @@ default: &default
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 10 } %>
timeout: 5000
default_transaction_mode: immediate
# Checkpoint on a background connection instead (SqliteWalCheckpoint).
# Non-test processes start a contender; forking servers stop before fork and
# start again in the child so the flock is never inherited.
pragmas:
wal_autocheckpoint: 0
development:
primary:
@@ -7,7 +7,6 @@
# can read the public login page. Require a valid Campfire session before an
# anonymous caller can allocate a Blob or persist bytes to disk.
Rails.application.config.to_prepare do
ActiveStorage::BaseController.forgery_protection_verification_strategy = :header_only
ActiveStorage::DirectUploadsController.include ActiveStorageAuthentication
ActiveStorage::DirectUploadsController.before_action :require_active_storage_authentication
-24
View File
@@ -1,24 +0,0 @@
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
@@ -1,5 +0,0 @@
# 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 -1
View File
@@ -1,4 +1,4 @@
Rails.application.config.session_store :cookie_store,
key: "_campfire_session",
# Preserve the existing installation's persistent browser-session cookie.
# Persist session cookie as permament so re-opened browser windows maintain a CSRF token
expire_after: 20.years
@@ -1,8 +0,0 @@
# Loaded from lib/rails_ext (autoload_lib ignore list) so reloads do not orphan
# the contender thread. Non-test processes start here; Puma/Resque stop before
# fork and start again in the child.
require Rails.root.join("lib/rails_ext/sqlite_wal_checkpoint")
Rails.application.config.after_initialize do
SqliteWalCheckpoint.start unless Rails.env.test?
end
+1 -1
View File
@@ -29,7 +29,7 @@ module WebPush::PersistentRequest
# itself and re-resolve the endpoint host, so http.ipaddr would no longer
# pin the destination and the DNS-rebinding guarantee would be lost.
# Push delivery to public vendor endpoints goes direct.
http = (@options[:connection] ? WebPush::Connections::HTTP : Net::HTTP).new(uri.host, uri.port, nil)
http = Net::HTTP.new(uri.host, uri.port, nil)
http.ipaddr = endpoint_ip
http.use_ssl = true
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
-6
View File
@@ -50,12 +50,6 @@ plugin :tmp_restart
# Reset all membership connections
Membership.disconnect_all
# Initializer starts a contender in this process. Stop before fork so workers
# do not inherit the flock; each worker starts its own contender. Single-process
# mode never forks, so the initializer's contender keeps running.
before_fork { SqliteWalCheckpoint.stop }
before_worker_boot { SqliteWalCheckpoint.start }
Signal.trap :SIGPROF do
Thread.list.each do |t|
puts t
@@ -1,15 +0,0 @@
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
+1 -3
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_07_190000) do
ActiveRecord::Schema[8.2].define(version: 2026_10_05_022000) do
create_table "accounts", force: :cascade do |t|
t.datetime "created_at", null: false
t.text "custom_styles"
@@ -124,7 +124,6 @@ ActiveRecord::Schema[8.2].define(version: 2026_10_07_190000) 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|
@@ -184,4 +183,3 @@ ActiveRecord::Schema[8.2].define(version: 2026_10_07_190000) 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
-187
View File
@@ -1,187 +0,0 @@
# SQLite's default WAL auto-checkpoint (~1,000 pages) runs inside the committing
# writer and fsyncs during the request. database.yml sets wal_autocheckpoint=0;
# this module copies pages off the request thread with PASSIVE checkpoints.
#
# Every non-test process starts a contender. Callers that fork (Puma, Resque pool)
# must stop before fork and start again in the child so the flock is never shared
# across an inherited file descriptor.
module SqliteWalCheckpoint
INTERVAL = 0.25
MAX_BACKOFF = 30.0
STOP_TIMEOUT = 5.0
LOCK_PATH = Rails.root.join("tmp/pids/sqlite_wal_checkpoint.lock")
LIFECYCLE_MUTEX = Mutex.new
private_constant :LIFECYCLE_MUTEX
class StopTimeout < StandardError; end
class << self
attr_writer :lock_path, :database_path_override
def start(interval: INTERVAL, enabled: !Rails.env.test?)
return unless enabled
LIFECYCLE_MUTEX.synchronize do
if @thread&.alive?
raise StopTimeout, "SQLite WAL checkpointer is still stopping" if @wakeup.closed?
return @thread
end
@wakeup = Thread::Queue.new
install_exit_checkpoint
@thread = Thread.new(@wakeup) { |wakeup| run(interval, wakeup) }
@thread.report_on_exception = false
@thread
end
end
# Signal the contender to exit and wait until it has released the flock and
# closed its SQLite connection. before_fork must not return while those are open.
def stop(timeout: STOP_TIMEOUT)
LIFECYCLE_MUTEX.synchronize do
return unless @thread
@wakeup.close
unless @thread.join(timeout)
# Never let a caller fork with live SQLite or flock ownership.
raise StopTimeout, "SQLite WAL checkpointer did not stop within #{timeout}s"
end
@thread = @wakeup = nil
end
end
def checkpoint
with_database { |database| checkpoint_on(database) }
end
# One leadership attempt for tests: acquire the lock, checkpoint once, release.
def tick
return :no_database unless database_path
lock = acquire_lock
return :busy unless lock
begin
result = checkpoint
result.nil? ? :no_database : :checkpointed
ensure
release_lock(lock)
end
end
def lock_path
@lock_path || LOCK_PATH
end
def reset!
stop
@lock_path = nil
@database_path_override = nil
end
private
def run(interval, wakeup)
Thread.current.name = "sqlite-wal-checkpoint"
backoff = interval
until wakeup.closed?
begin
path = database_path
unless path && File.exist?(path)
wait(wakeup, interval)
next
end
lock = acquire_lock
unless lock
wait(wakeup, interval)
next
end
begin
ran = false
with_database do |database|
ran = true
until wakeup.closed?
checkpoint_on(database)
backoff = interval
wait(wakeup, interval)
end
end
ensure
release_lock(lock)
end
# with_database no-ops if the file vanished between the exist? check
# and open; sleep so we do not spin on the lock file.
wait(wakeup, interval) unless ran || wakeup.closed?
rescue => error
Rails.logger.warn "SQLite WAL checkpoint failed: #{error.class}: #{error.message}"
wait(wakeup, backoff)
backoff = [ backoff * 2, MAX_BACKOFF ].min
end
end
end
def wait(wakeup, timeout)
wakeup.pop(timeout: timeout)
end
def checkpoint_on(database)
database.execute("PRAGMA wal_checkpoint(PASSIVE)").first
end
def with_database
path = database_path
return unless path && File.exist?(path)
result = nil
SQLite3::Database.new(path) do |database|
# Bounds busy-handler retries, not PASSIVE checkpoint I/O.
database.busy_handler_timeout = 1_000
result = yield database
end
result
end
def database_path
return @database_path_override if @database_path_override
config = ActiveRecord::Base.connection_db_config
return unless config.adapter.to_s == "sqlite3"
ActiveRecord::ConnectionAdapters::SQLite3Adapter.resolve_path(config.database)
end
def acquire_lock
FileUtils.mkdir_p(File.dirname(lock_path))
file = File.open(lock_path, File::RDWR | File::CREAT, 0644)
if file.flock(File::LOCK_EX | File::LOCK_NB)
file
else
file.close
nil
end
rescue
file&.close
raise
end
def release_lock(file)
return unless file
file.flock(File::LOCK_UN)
ensure
file&.close
end
# Best-effort PASSIVE for short-lived console/rake writers that exit before
# the contender acquires the flock. Does not touch lock ownership.
def install_exit_checkpoint
return if @exit_checkpoint_installed
@exit_checkpoint_installed = true
at_exit { checkpoint rescue nil }
end
end
end
-4
View File
@@ -4,13 +4,9 @@ end
task "resque:pool:setup" do
ActiveRecord::Base.connection.disconnect!
# Initializer may have started a contender in the pool master; drop it before
# workers fork so they do not inherit the flock.
SqliteWalCheckpoint.stop
Resque::Pool.after_prefork do |job|
ActiveRecord::Base.establish_connection
Resque.redis.client.close
SqliteWalCheckpoint.start
end
end
-29
View File
@@ -1,29 +0,0 @@
# 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
+3 -8
View File
@@ -26,12 +26,6 @@ class WebPush::Connections
end
end
# The Net::HTTP a pooled delivery is opened with: Stages is part of the class, so a connection carries its stage
# from the start instead of having the module mixed into each one as it's opened.
class HTTP < Net::HTTP
include Stages
end
def initialize(keep_alive_timeout: 30, max_idle: 150)
@keep_alive_timeout = keep_alive_timeout
@max_idle = max_idle
@@ -41,8 +35,8 @@ class WebPush::Connections
end
def request(http, request)
unless http.is_a?(HTTP) && http.ipaddr && !http.proxy? && http.use_ssl? && !http.started?
raise ArgumentError, "Only new, direct TLS connections pinned to an address and opened as WebPush::Connections::HTTP are pooled"
unless http.ipaddr && !http.proxy? && http.use_ssl? && !http.started?
raise ArgumentError, "Only new, direct TLS connections pinned to an address are pooled"
end
address = [ http.address, http.port, http.ipaddr ]
@@ -54,6 +48,7 @@ class WebPush::Connections
end
end
http.extend Stages
http.keep_alive_timeout = @keep_alive_timeout
http.start
send_over(http, request, reused: false).tap { checkin(address, http) }
@@ -18,53 +18,4 @@ class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase
assert_reject_connection { connect }
end
test "closes once its session has ended without it hearing" do
cookies.signed[:session_token] = sessions(:david_safari).token
connect
sessions(:david_safari).destroy
travel 2.minutes
connection.beat
assert socket.closed
assert_equal "disconnect", socket.transmissions.last[:type]
assert socket.transmissions.last[:reconnect]
end
test "keeps checking its session while open" do
cookies.signed[:session_token] = sessions(:david_safari).token
connect
travel 2.minutes
connection.beat
assert_not socket.closed
sessions(:david_safari).destroy
travel 2.minutes
connection.beat
assert socket.closed
end
test "checks its session at most once a minute" do
cookies.signed[:session_token] = sessions(:david_safari).token
connect
sessions(:david_safari).destroy
travel 30.seconds
connection.beat
assert_not socket.closed
end
test "a rejected connection's heartbeat doesn't fail" do
request = ActionCable::Connection::TestSocket.build_request(ActionCable.server.config.mount_path || "/cable", cookies: cookies)
rejected = ApplicationCable::Connection.new \
ActionCable::Connection::TestServer.new(ActionCable.server), ActionCable::Connection::TestSocket.new(request)
rejected.handle_open
travel 2.minutes
assert_nothing_raised { rejected.beat }
end
end
-217
View File
@@ -1,217 +0,0 @@
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 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 "a reused page carries the same enforcing policy, with no nonce" do
get room_messages_url(@room)
first = response.headers["Content-Security-Policy"]
assert_match %r{script-src [^/ ]+/assets/ 'sha256-}, first
assert_no_match "nonce-", first
MessagesController.any_instance.expects(:find_paged_messages).never
get room_messages_url(@room)
assert_response :success
assert_equal first, response.headers["Content-Security-Policy"]
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 token state reuse complete HTML and post without tokens" do
cookies["_campfire_session"] = @login_cookie
get room_url(@room)
first = response.body
assert_select 'meta[name="csrf-token"]', count: 0
assert_select 'input[name="authenticity_token"]', count: 0
cookies["_campfire_session"] = @login_cookie
ResponseCache.instance.expects(:write).never
get room_url(@room)
assert_response :success
assert_equal first, response.body
post room_messages_url(@room, format: :turbo_stream), params: {
message: { body: "tokenless replay works", client_message_id: "cache-replay" } },
headers: { "Sec-Fetch-Site" => "same-origin", "Origin" => "http://once.campfire.test" }
assert_response :success
end
test "literal token-like text survives complete page reuse" do
literal = "campfire-csrf-literal authenticity_token csrf-token"
@room.messages.create!(creator: users(:david), body: "literal #{literal}")
get room_url(@room)
first = response.body
get room_url(@room)
assert_equal first, response.body
assert_includes response.body, "literal #{literal}"
end
test "gzip bytes are reused and identity negotiation stays separate" do
require "stringio"
get room_url(@room), headers: { "Accept-Encoding" => "gzip" }
assert_equal "gzip", response.headers["Content-Encoding"]
encoded = response.body
decoded = Zlib::GzipReader.new(StringIO.new(encoded)).read
assert_includes decoded, "<html"
assert_includes response.headers["Vary"], "Accept-Encoding"
get room_url(@room)
assert_equal decoded, response.body
ResponseCache.instance.expects(:write).never
get room_url(@room), headers: { "Accept-Encoding" => "gzip" }
assert_equal encoded, response.body
get room_url(@room)
assert_nil response.headers["Content-Encoding"]
assert_equal decoded, response.body
get room_url(@room), headers: { "Accept-Encoding" => "gzip;q=0, identity;q=1" }
assert_nil response.headers["Content-Encoding"]
assert_equal decoded, response.body
get room_url(@room), headers: { "Accept-Encoding" => "*;q=0" }
assert_nil response.headers["Content-Encoding"]
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 "a session signed out by a password change isn't served from a cached snapshot or page" do
other_device = open_session { |session| session.host! "once.campfire.test" }
other_device.post session_url, params: { email_address: users(:david).email_address, password: "secret123456" }
other_device.get user_sidebar_url(:me)
other_device.assert_response :success
Session.expects(:find_by).never
ResponseCache.instance.expects(:write).never
other_device.get user_sidebar_url(:me)
other_device.assert_response :success
Session.unstub(:find_by)
ResponseCache.instance.unstub(:write)
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } },
headers: { "Sec-Fetch-Site" => "same-origin", "Origin" => "http://once.campfire.test" }
assert_redirected_to user_profile_url
other_device.get user_sidebar_url(:me)
other_device.assert_redirected_to new_session_url
get user_sidebar_url(:me)
assert_response :success
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
-91
View File
@@ -1,91 +0,0 @@
require "test_helper"
class FetchMetadataTest < ActionDispatch::IntegrationTest
setup do
host! "once.campfire.test"
@previous_exceptions = Rails.application.env_config["action_dispatch.show_exceptions"]
Rails.application.env_config["action_dispatch.show_exceptions"] = :rescuable
@previous_forgery = ActionController::Base.allow_forgery_protection
ActionController::Base.allow_forgery_protection = true
end
teardown do
ActionController::Base.allow_forgery_protection = @previous_forgery
Rails.application.env_config["action_dispatch.show_exceptions"] = @previous_exceptions
end
test "login and authenticated forms have no token fields" do
get new_session_url
assert_response :success
assert_select 'meta[name="csrf-token"]', count: 0
assert_select 'input[name="authenticity_token"]', count: 0
sign_in :david
get room_url(rooms(:watercooler))
assert_response :success
assert_select 'meta[name="csrf-token"]', count: 0
assert_select 'input[name="authenticity_token"]', count: 0
end
test "HTTPS login accepts browser metadata without a token" do
https!
%w[ same-origin same-site ].each do |site|
reset!
host! "once.campfire.test"
https!
post session_url, params: credentials, headers: {
"Sec-Fetch-Site" => site, "Origin" => "https://once.campfire.test" }
assert_response :redirect
assert cookies["session_token"].present?
end
end
test "HTTPS writes reject missing metadata even with a legacy token parameter" do
https!
assert_no_difference "Session.count" do
post session_url, params: credentials.merge(authenticity_token: "old token"), headers: { "Origin" => "https://once.campfire.test" }
end
assert_response :unprocessable_entity
end
test "cross-site none and malformed metadata cannot sign in over HTTP or HTTPS" do
[ false, true ].each do |tls|
https! tls
%w[ cross-site none garbage ].each do |site|
assert_no_difference "Session.count" do
post session_url, params: credentials, headers: { "Sec-Fetch-Site" => site }
end
assert_response :unprocessable_entity
end
end
end
test "provided foreign and null origins fail even with same-site metadata" do
%w[ https://foreign.example null ].each do |origin|
assert_no_difference "Session.count" do
post session_url, params: credentials, headers: { "Sec-Fetch-Site" => "same-site", "Origin" => origin }
end
assert_response :unprocessable_entity
end
end
test "plain HTTP retains the missing-header fallback" do
post session_url, params: credentials, headers: { "Origin" => "http://once.campfire.test" }
assert_response :redirect
assert cookies["session_token"].present?
end
test "cross-site reads remain available and method-overridden writes stay protected" do
get new_session_url, headers: { "Sec-Fetch-Site" => "cross-site" }
assert_response :success
sign_in :david
assert_no_difference "Room.count" do
post room_path(rooms(:watercooler)), params: { _method: "delete" }, headers: { "Sec-Fetch-Site" => "cross-site" }
end
assert_response :unprocessable_entity
end
private
def credentials
{ email_address: users(:david).email_address, password: "secret123456" }
end
end
-170
View File
@@ -1,170 +0,0 @@
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 "message controls survive creator edits while the author is rendered fresh" do
get room_messages_url(@room)
assert_response :success
foreign_write("UPDATE users SET name = ? WHERE id = ?", "Fresh creator with cached controls", @message.creator_id)
ActionView::Base.any_instance.expects(:form_with).never
get room_messages_url(@room)
assert_response :success
assert_includes response.body, "Fresh creator with cached controls"
assert_select "form[action=?]", message_boosts_path(@message)
end
test "a commit in another room retains content-validated message collection hits" do
ResponseCache.instance.stubs(:budget).returns(0)
get room_messages_url(@room)
assert_response :success
foreign_write("UPDATE rooms SET name = ? WHERE id = ?", "Unrelated room rename", rooms(:pets).id)
collections = []
ActiveSupport::Notifications.subscribed(->(event) { collections << event.payload }, "render_collection.action_view") do
get room_messages_url(@room)
end
assert_response :success
messages = collections.find { |payload| payload[:identifier].end_with?("messages/_message.html.erb") }
assert messages
assert_equal @room.messages.count, messages[:cache_hits]
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
@@ -31,32 +31,6 @@ class Messages::BoostsControllerTest < ActionDispatch::IntegrationTest
end
end
test "quick boost controls submit their displayed reaction to the message" do
get room_url(@message.room)
forms = css_select("##{dom_id(@message)} .quick-boosts form")
assert_equal EmojiHelper::REACTIONS.size, forms.size
forms.each do |form|
assert_equal "post", form["method"]
assert_equal message_boosts_path(@message), form["action"]
assert_equal dom_id(@message, :boosting), form["data-turbo-frame"]
assert_equal "popup#close", form["data-action"]
input = form.at_css('input[name="boost[content]"]')
button = form.at_css('button[type="submit"]')
assert_equal input["value"], button["data-emoji"]
assert_equal EmojiHelper::REACTIONS.fetch(input["value"]), button["title"]
assert_nil form.at_css('input[name="authenticity_token"]')
end
form = forms.first
reaction = form.at_css('input[name="boost[content]"]')["value"]
assert_difference -> { @message.boosts.count }, 1 do
post form["action"], params: { boost: { content: reaction } }, headers: {
"Sec-Fetch-Site" => "same-origin", "Origin" => "http://www.example.com" }
assert_redirected_to message_boosts_url(@message)
end
assert_equal reaction, @message.boosts.order(:id).last.content
end
test "destroy" do
assert_turbo_stream_broadcasts [ @message.room, :messages ], count: 1 do
assert_difference -> { @message.boosts.count }, -1 do
@@ -1,9 +1,6 @@
require "test_helper"
require "active_record/testing/query_assertions"
class Messages::ByBotsControllerTest < ActionDispatch::IntegrationTest
include ActiveRecord::Assertions::QueryAssertions
setup do
@room = rooms(:watercooler)
end
@@ -96,9 +93,7 @@ class Messages::ByBotsControllerTest < ActionDispatch::IntegrationTest
@room.messages.create!(body: "Filler #{i}", creator: users(:jason), client_message_id: "filler-#{i}")
end
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)
@@ -124,10 +119,7 @@ class Messages::ByBotsControllerTest < ActionDispatch::IntegrationTest
end
test "index in a room with no messages" do
room = rooms(:bender_and_kevin)
assert_equal 0, room.messages_count
get room_bot_messages_url(room, users(:bender).bot_key)
get room_bot_messages_url(rooms(:bender_and_kevin), users(:bender).bot_key)
assert_response :success
assert_equal [], JSON.parse(response.body)
@@ -49,12 +49,8 @@ class MessagesControllerTest < ActionDispatch::IntegrationTest
end
test "creating a message broadcasts the message to the room" do
ContentFilters::TextMessagePresentationFilters.expects(:apply).once.with { |body| body.to_plain_text == "New one" }.returns(ActionText::Content.new("New one"))
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } }
assert_response :success
assert_select "turbo-stream[action=append] .message__body", text: /New one/
assert_rendered_turbo_stream_broadcast @room, :messages, action: "append", target: [ @room, :messages ] do
assert_select ".message__body", text: /New one/
assert_copy_link_button room_at_message_url(@room, Message.last, host: "once.campfire.test")
@@ -12,18 +12,4 @@ class QrCodeControllerTest < ActionDispatch::IntegrationTest
assert_equal 1.year, response.cache_control[:max_age].to_i
assert response.cache_control[:public]
end
test "show rejects an id that isn't base64" do
get qr_code_path("not-base64!")
assert_response :bad_request
end
test "show rejects a URL too long for a QR code" do
id = Base64.urlsafe_encode64("http://example.com/" + "a" * 3000)
get qr_code_path(id)
assert_response :bad_request
end
end
@@ -30,19 +30,4 @@ class Sessions::TransfersControllerTest < ActionDispatch::IntegrationTest
assert_response :bad_request
assert_nil parsed_cookies.signed[:session_token]
end
test "update is refused when the password changes after the link was checked" do
user = users(:david)
transfer_id = user.transfer_id
stale = User.find(user.id)
User.find(user.id).update!(password: "a new secret123456")
User.stubs(:find_by_transfer_id).with(transfer_id).returns(stale)
assert_no_difference -> { Session.count } do
put session_transfer_url(transfer_id)
end
assert_response :bad_request
assert_nil parsed_cookies.signed[:session_token]
end
end
@@ -36,19 +36,6 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
assert parsed_cookies.signed[:session_token]
end
test "create is refused when the password changes after it was checked" do
user = User.find(users(:david).id)
User.find(user.id).update!(password: "a new secret123456")
User.stubs(:authenticate_by).returns(user)
assert_no_difference -> { Session.count } do
post session_url, params: { email_address: "david@37signals.com", password: "secret123456" }
end
assert_response :unauthorized
assert_nil parsed_cookies.signed[:session_token]
end
test "create with invalid credentials" do
post session_url, params: { email_address: "david@37signals.com", password: "wrong" }
@@ -43,16 +43,6 @@ class Users::ProfilesControllerTest < ActionDispatch::IntegrationTest
assert_equal 1, users(:david).sessions.count
end
test "changing the password signs this browser out when it changes again before its new session starts" do
User.stubs(:uncached).returns(BCrypt::Password.create("someone else's secret", cost: BCrypt::Engine::MIN_COST))
assert_changes -> { users(:david).sessions.count }, to: 0 do
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
end
assert_redirected_to new_session_url
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]
@@ -28,23 +28,6 @@ class Users::SidebarsControllerTest < ActionDispatch::IntegrationTest
assert_select ".unread", count: users(:david).memberships.reject { |m| m.room.direct? || !m.unread? }.count
end
test "a cached direct that is already unread still sorts by its latest message" do
room = rooms(:david_and_jason)
with_memory_cache do
travel_to 1.hour.ago do
room.messages.create! client_message_id: 998, body: "First", creator: users(:jason)
end
get user_sidebar_url
assert memberships(:david_david_and_jason).reload.unread?
room.messages.create! client_message_id: 999, body: "Second", creator: users(:jason)
get user_sidebar_url
assert_select "##{dom_id(room, :list)}[data-sorted-list-number=?]", room.reload.updated_at.to_fs(:epoch)
end
end
test "directs are ordered by room recency, not name" do
older = rooms(:david_and_jason)
newer = rooms(:david_and_kevin)
@@ -65,23 +48,4 @@ class Users::SidebarsControllerTest < ActionDispatch::IntegrationTest
assert positions.all?
assert_equal positions.sort, positions
end
private
def with_memory_cache
old_cache = Rails.cache
old_collection_cache = ActionView::PartialRenderer.collection_cache
old_controller_cache = Users::SidebarsController.cache_store
old_caching = Users::SidebarsController.perform_caching
Rails.cache = ActiveSupport::Cache::MemoryStore.new
ActionView::PartialRenderer.collection_cache = Rails.cache
Users::SidebarsController.cache_store = Rails.cache
Users::SidebarsController.perform_caching = true
yield
ensure
Rails.cache = old_cache
ActionView::PartialRenderer.collection_cache = old_collection_cache
Users::SidebarsController.cache_store = old_controller_cache
Users::SidebarsController.perform_caching = old_caching
end
end
-12
View File
@@ -1,18 +1,6 @@
require "test_helper"
class MessagesHelperTest < ActionView::TestCase
test "plain text presentation is reused by content rather than database epoch" do
view.controller.stubs(:perform_caching).returns(true)
FragmentCache.store.clear
message = Message.create! room: rooms(:pets), body: "A reusable <strong>safe</strong> body", creator: users(:jason)
first = view.message_presentation(message)
assert_includes first, "A reusable <strong>safe</strong> body"
ContentFilters::TextMessagePresentationFilters.expects(:apply).never
assert_equal first, view.message_presentation(message)
ensure
FragmentCache.store.clear
end
test "message_presentation neutralizes unsafe URI schemes in links" do
message = Message.create! room: rooms(:pets), body: '<div><a href="javascript:alert(1)">x</a></div>', client_message_id: "0015", creator: users(:jason)
@@ -103,16 +103,6 @@ class ContentSecurityPolicyTest < ActionDispatch::IntegrationTest
assert_equal "www.example.com:8443/service-worker.js", policy[/worker-src ([^;]+)/, 1]
end
test "each host's pages carry that host's sources, one after another" do
[ "www.example.com", "www.example.com:8443", "www.example.com" ].each do |host|
host! host
get PAGE_PATH
follow_redirect! while response.redirect?
assert_includes response.headers["Content-Security-Policy"][/script-src ([^;]+)/, 1].split, "#{host}/assets/"
end
end
test "uploads can't load as scripts" do
get PAGE_PATH
follow_redirect! while response.redirect?
-313
View File
@@ -1,313 +0,0 @@
require "test_helper"
class SqliteWalCheckpointTest < ActiveSupport::TestCase
setup do
@tmpdir = Dir.mktmpdir("sqlite-wal-checkpoint")
SqliteWalCheckpoint.reset!
SqliteWalCheckpoint.lock_path = File.join(@tmpdir, "checkpoint.lock")
end
teardown do
SqliteWalCheckpoint.reset!
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.directory?(@tmpdir)
end
test "connections disable WAL auto-checkpoint so commits do not fsync on the writer" do
assert_equal 0, ActiveRecord::Base.connection.select_value("PRAGMA wal_autocheckpoint").to_i
end
test "a passive checkpoint against the primary database does not raise" do
assert_nothing_raised { SqliteWalCheckpoint.checkpoint }
end
test "does not start a background thread in test by default" do
assert_nil SqliteWalCheckpoint.start
assert_empty checkpoint_threads
end
test "start with enabled: true runs a named contender thread that stop joins" do
thread = SqliteWalCheckpoint.start(interval: 0.05, enabled: true)
assert thread.alive?
wait_until { thread.name == "sqlite-wal-checkpoint" }
SqliteWalCheckpoint.stop
assert_not thread.alive?
assert_empty checkpoint_threads
end
test "stop before start again mimics a fork-safe worker boot" do
db_path = build_wal_database(rows: 10)
SqliteWalCheckpoint.database_path_override = db_path
first = SqliteWalCheckpoint.start(interval: 0.05, enabled: true)
wait_until { first.name == "sqlite-wal-checkpoint" }
SqliteWalCheckpoint.stop
assert_not first.alive?
second = SqliteWalCheckpoint.start(interval: 0.05, enabled: true)
wait_until { second.name == "sqlite-wal-checkpoint" }
assert second.alive?
SqliteWalCheckpoint.stop
assert_not second.alive?
assert_equal :checkpointed, SqliteWalCheckpoint.tick
end
test "stop interrupts a long checkpoint interval" do
waits = observe_waits
contender = SqliteWalCheckpoint.start(interval: 30, enabled: true)
assert_equal 30, receive(waits)
SqliteWalCheckpoint.stop(timeout: 0.5)
assert_not contender.alive?
assert_empty checkpoint_threads
end
test "stop interrupts checkpoint error backoff" do
SqliteWalCheckpoint.database_path_override = build_wal_database(rows: 10)
SqliteWalCheckpoint.stubs(:checkpoint_on).raises(SQLite3::Exception, "checkpoint failed")
waits = observe_waits
contender = SqliteWalCheckpoint.start(interval: 30, enabled: true)
assert_equal 30, receive(waits)
SqliteWalCheckpoint.stop(timeout: 0.5)
assert_not contender.alive?
assert_not lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
end
test "timed out stop prevents replacement until the checkpoint releases its resources" do
entered, release = block_checkpoint
contender = SqliteWalCheckpoint.start(enabled: true)
database = receive(entered)
assert lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
assert_raises(SqliteWalCheckpoint::StopTimeout) { SqliteWalCheckpoint.stop(timeout: 0.01) }
assert contender.alive?
assert lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
assert_raises(SqliteWalCheckpoint::StopTimeout) { SqliteWalCheckpoint.start(enabled: true) }
assert_equal [ contender ], checkpoint_threads
release << true
SqliteWalCheckpoint.stop
assert_not contender.alive?
assert database.closed?
assert_not lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
ensure
release&.push(true)
SqliteWalCheckpoint.stop
end
test "start waits for a concurrent stop to release the previous contender" do
entered, release = block_checkpoint
contender = SqliteWalCheckpoint.start(enabled: true)
receive(entered)
stopping = Thread.new { SqliteWalCheckpoint.stop }
wait_until { stopping.status == "sleep" }
starting = Thread.new { SqliteWalCheckpoint.start(enabled: true) }
wait_until { starting.status == "sleep" }
assert contender.alive?
release << true
stopping.value
replacement = starting.value
assert_not contender.alive?
assert replacement.alive?
assert_not_equal contender, replacement
ensure
release&.push(true)
stopping&.join
starting&.join
SqliteWalCheckpoint.stop
end
test "a contender cannot release a concurrent tick's lock" do
entered, release = block_checkpoint
ticking = Thread.new { SqliteWalCheckpoint.tick }
receive(entered)
waits = observe_waits
contender = SqliteWalCheckpoint.start(interval: 30, enabled: true)
assert_equal 30, receive(waits)
SqliteWalCheckpoint.stop(timeout: 0.5)
assert_not contender.alive?
assert lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
release << true
assert_equal :checkpointed, ticking.value
assert_not lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
ensure
release&.push(true)
ticking&.join
SqliteWalCheckpoint.stop
end
test "reset does not reinstall the process exit checkpoint" do
SqliteWalCheckpoint.start(enabled: true)
SqliteWalCheckpoint.reset!
SqliteWalCheckpoint.expects(:at_exit).never
SqliteWalCheckpoint.start(enabled: true)
SqliteWalCheckpoint.stop
end
test "tick checkpoints through the elected lock holder" do
db_path = build_wal_database(rows: 50)
SqliteWalCheckpoint.database_path_override = db_path
assert_equal :checkpointed, SqliteWalCheckpoint.tick
end
test "tick reports busy while another process holds the lock and succeeds after it exits" do
db_path = build_wal_database(rows: 20)
SqliteWalCheckpoint.database_path_override = db_path
lock = SqliteWalCheckpoint.lock_path
ready = File.join(@tmpdir, "holder-ready")
holder = spawn_lock_holder(lock, ready_path: ready, hold_for: 0.4)
begin
wait_until { File.exist?(ready) }
assert_equal :busy, SqliteWalCheckpoint.tick
ensure
Process.wait(holder)
end
assert_equal :checkpointed, SqliteWalCheckpoint.tick
end
test "passive checkpoint moves WAL pages after writes with autocheckpoint disabled" do
db_path = File.join(@tmpdir, "writer-#{SecureRandom.hex(4)}.sqlite3")
SQLite3::Database.new(db_path) do |writer|
writer.execute("PRAGMA journal_mode=WAL")
writer.execute("PRAGMA wal_autocheckpoint=0")
writer.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, body TEXT)")
200.times do |i|
writer.execute("INSERT INTO items (body) VALUES (?)", "row-#{i}-#{"x" * 200}")
end
wal_path = "#{db_path}-wal"
assert File.exist?(wal_path), "expected a WAL file after inserts"
assert File.size(wal_path) > 0
SqliteWalCheckpoint.database_path_override = db_path
lock = SqliteWalCheckpoint.send(:acquire_lock)
assert lock
begin
_busy, _log, checkpointed = SqliteWalCheckpoint.checkpoint
assert checkpointed.to_i > 0, "expected PASSIVE checkpoint to copy WAL pages, got #{checkpointed.inspect}"
ensure
SqliteWalCheckpoint.send(:release_lock, lock)
end
end
end
test "a contender thread takes over checkpointing after the lock holder stops" do
db_path = build_wal_database(rows: 30)
SqliteWalCheckpoint.database_path_override = db_path
lock = SqliteWalCheckpoint.lock_path
ready = File.join(@tmpdir, "holder-ready")
holder = spawn_lock_holder(lock, ready_path: ready, hold_for: 0.25)
wait_until { File.exist?(ready) }
contender = SqliteWalCheckpoint.start(interval: 0.05, enabled: true)
begin
assert_equal :busy, SqliteWalCheckpoint.tick
Process.wait(holder)
holder = nil
wait_until(timeout: 2) { lock_held_by_other_process?(lock) }
ensure
Process.wait(holder) if holder
SqliteWalCheckpoint.stop
assert_not contender.alive?
end
end
private
def observe_waits
waits = Thread::Queue.new
SqliteWalCheckpoint.stubs(:wait).with do |wakeup, timeout|
waits << timeout
wakeup.pop(timeout: timeout)
true
end
waits
end
def block_checkpoint
SqliteWalCheckpoint.database_path_override = build_wal_database(rows: 10)
entered = Thread::Queue.new
release = Thread::Queue.new
blocked = false
SqliteWalCheckpoint.stubs(:checkpoint_on).returns([ 0, 0, 0 ]).with do |database|
unless blocked
blocked = true
entered << database
release.pop
end
true
end
[ entered, release ]
end
def receive(queue)
queue.pop(timeout: 2).tap { |value| assert_not_nil value, "expected checkpoint barrier" }
end
def checkpoint_threads
Thread.list.select { |thread| thread.name == "sqlite-wal-checkpoint" }
end
def build_wal_database(rows:)
path = File.join(@tmpdir, "writer-#{SecureRandom.hex(4)}.sqlite3")
SQLite3::Database.new(path) do |database|
database.execute("PRAGMA journal_mode=WAL")
database.execute("PRAGMA wal_autocheckpoint=0")
database.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, body TEXT)")
rows.times do |i|
database.execute("INSERT INTO items (body) VALUES (?)", "row-#{i}-#{"x" * 200}")
end
end
path
end
def spawn_lock_holder(lock, ready_path:, hold_for:)
Process.spawn(
RbConfig.ruby, "-e", <<~RUBY
require "fileutils"
lock = #{lock.inspect}
ready = #{ready_path.inspect}
FileUtils.mkdir_p(File.dirname(lock))
file = File.open(lock, File::RDWR | File::CREAT, 0644)
abort "lock failed" unless file.flock(File::LOCK_EX | File::LOCK_NB)
File.write(ready, "1")
sleep #{hold_for}
RUBY
)
end
def lock_held_by_other_process?(lock)
return false unless File.exist?(lock)
probe = File.open(lock, File::RDWR | File::CREAT, 0644)
if probe.flock(File::LOCK_EX | File::LOCK_NB)
probe.flock(File::LOCK_UN)
probe.close
false
else
probe.close
true
end
end
def wait_until(timeout: 1)
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
until yield
raise "condition not met within #{timeout}s" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
sleep 0.02
end
end
end
+13 -8
View File
@@ -10,6 +10,11 @@ class VipsLoaderPolicyTest < ActiveSupport::TestCase
# ones are represented by their magic bytes.
FTYP_AVIF = "\x00\x00\x00\x1cftypavif\x00\x00\x00\x00avifmif1miaf".b
FTYP_HEIC = "\x00\x00\x00\x1cftypheic\x00\x00\x00\x00heicmif1miaf".b
BMP = "BM" + [ 0, 0, 54 ].pack("V3") + "\x00" * 40
PSD = "8BPS" + [ 1 ].pack("n") + "\x00" * 26
ICO = "\x00\x00\x01\x00\x01\x00" + "\x00" * 16
SVG = %q(<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"/>)
test "loads PNG" do
assert_equal "VipsForeignLoadPngFile", loader_for(encode("png"))
end
@@ -38,20 +43,20 @@ class VipsLoaderPolicyTest < ActiveSupport::TestCase
assert_equal "VipsForeignLoadHeifFile", loader_for(FTYP_HEIC)
end
test "blocks BMP through magickload" do
assert_loader_blocked :magickload, ".bmp"
test "denies BMP through magickload" do
assert_nil loader_for(BMP)
end
test "blocks PSD through magickload" do
assert_loader_blocked :magickload, ".psd"
test "denies PSD through magickload" do
assert_nil loader_for(PSD)
end
test "blocks ICO through magickload" do
assert_loader_blocked :magickload, ".ico"
test "denies ICO through magickload" do
assert_nil loader_for(ICO)
end
test "blocks SVG through svgload" do
assert_loader_blocked :svgload, ".svg"
test "denies SVG through svgload" do
assert_nil loader_for(SVG)
end
test "denies OpenSlide files through openslideload" do
+7 -10
View File
@@ -76,13 +76,11 @@ class WebPush::ConnectionsTest < ActiveSupport::TestCase
end
end
class FailingCheckHTTP < WebPush::Connections::HTTP
private def begin_transport(*) = raise(OpenSSL::SSL::SSLError, "alert right after the handshake")
end
test "a TLS failure on a new connection before the push is written is reported as it is" do
failing_check = Module.new { private def begin_transport(*) = raise(OpenSSL::SSL::SSLError, "alert right after the handshake") }
with_push_service do |server|
assert_raises(OpenSSL::SSL::SSLError) { @connections.request(pinned_connection(server, http_class: FailingCheckHTTP), push_request) }
assert_raises(OpenSSL::SSL::SSLError) { @connections.request(pinned_connection(server).extend(failing_check), push_request) }
assert_empty server.requests
end
end
@@ -99,15 +97,14 @@ class WebPush::ConnectionsTest < ActiveSupport::TestCase
end
end
test "only new, direct TLS connections pinned to an address and opened as WebPush::Connections::HTTP are pooled" do
test "only new, direct TLS connections pinned to an address are pooled" do
with_push_service do |server|
unpinned = WebPush::Connections::HTTP.new(HOST, server.port, nil).tap { it.use_ssl = true }
proxied = WebPush::Connections::HTTP.new(HOST, server.port, "127.0.0.1", 3128).tap { it.ipaddr = IP; it.use_ssl = true }
unpinned = Net::HTTP.new(HOST, server.port, nil).tap { it.use_ssl = true }
proxied = Net::HTTP.new(HOST, server.port, "127.0.0.1", 3128).tap { it.ipaddr = IP; it.use_ssl = true }
plain = pinned_connection(server).tap { it.use_ssl = false }
started = pinned_connection(server).tap(&:start)
untracked = Net::HTTP.new(HOST, server.port, nil).tap { it.ipaddr = IP; it.use_ssl = true }
[ unpinned, proxied, plain, started, untracked ].each do |http|
[ unpinned, proxied, plain, started ].each do |http|
assert_raises(ArgumentError) { @connections.request(http, push_request) }
end
assert_empty server.requests
-132
View File
@@ -1,132 +0,0 @@
require "test_helper"
class Message::CreationTest < ActiveSupport::TestCase
include ActionDispatch::TestProcess
setup do
@room = rooms(:designers)
@creator = users(:david)
end
test "creation indexes persisted native HTML and mentions before notifying" do
html = "<p>Fish &amp; chips 🌍 #{mention_attachment_for(:jason)}</p>"
message = @room.messages.new(creator: @creator, body: html)
original = message.method(:create_in_index)
message.define_singleton_method(:create_in_index) do
raise "rich text not autosaved" unless ActionText::RichText.exists?(record: self)
original.call
end
message.save!
assert_equal message.body.to_plain_text, indexed_body(message)
assert_equal message.reload.body.to_plain_text, indexed_body(message)
assert_includes message.mentionees, users(:jason)
assert_equal [ message ], @room.messages.search("Fish chips")
assert_no_match(/<p>|&amp;/, indexed_body(message))
end
test "an attachment-only message is attached before its filename is indexed" do
message = @room.messages.new(creator: @creator, attachment: fixture_file_upload("moon.jpg", "image/jpeg"))
original = message.method(:create_in_index)
message.define_singleton_method(:create_in_index) do
raise "attachment not autosaved" unless ActiveStorage::Attachment.exists?(record: self, name: "attachment")
original.call
end
message.save!
assert_equal "moon.jpg", indexed_body(message)
assert_equal [ message ], @room.messages.search("moon")
end
test "a failed index insert rolls back the message and its associations without notifications" do
message = @room.messages.new(creator: @creator, body: "<p>Failed &amp; indexed</p>",
attachment: fixture_file_upload("moon.jpg", "image/jpeg"))
message.define_singleton_method(:create_in_index) { raise "index unavailable" }
assert_rolled_back_creation(message) do
assert_raises(RuntimeError) { message.save! }
end
end
test "a failed unread update rolls back the already inserted index and associations" do
message = @room.messages.new(creator: @creator, body: "<p>Unread &amp; rollback</p>",
attachment: fixture_file_upload("moon.jpg", "image/jpeg"))
original = @room.method(:unread_memberships)
@room.define_singleton_method(:unread_memberships) do |created|
original.call(created)
raise "unread unavailable"
end
assert_rolled_back_creation(message) do
assert_raises(RuntimeError) { message.save! }
end
end
test "an outer rollback restores the index unread state and counter and queues no jobs" do
message = @room.messages.new(creator: @creator, body: "<p>Outer &amp; rollback</p>")
assert_rolled_back_creation(message) do
Message.transaction(requires_new: true) do
message.save!
assert_equal "Outer & rollback", indexed_body(message)
raise ActiveRecord::Rollback
end
end
end
test "the existing public receive operation still marks unread and queues a push" do
message = messages(:first)
room = message.room
room.expects(:unread_memberships).with(message)
assert_enqueued_with(job: Room::PushMessageJob, args: [ room, message ]) { room.receive(message) }
end
private
def indexed_body(message)
Message.connection.select_value(Message.sanitize_sql([ "SELECT body FROM message_search_index WHERE rowid = ?", message.id ]))
end
def assert_rolled_back_creation(message)
models = [ Message, ActionText::RichText, ActiveStorage::Attachment, ActiveStorage::Blob ]
counts = models.map(&:count)
memberships = @room.memberships.order(:id).map(&:attributes)
room_state = @room.reload.attributes.slice("messages_count", "updated_at")
indexed = Message.connection.select_value("SELECT COUNT(*) FROM message_search_index")
clear_enqueued_jobs
yield
assert_equal counts, models.map(&:count)
assert_equal indexed, Message.connection.select_value("SELECT COUNT(*) FROM message_search_index")
assert_equal memberships, @room.memberships.order(:id).map(&:attributes)
assert_equal room_state, @room.reload.attributes.slice("messages_count", "updated_at")
assert_enqueued_jobs 0
end
end
class MessageCreationCommitTest < ActiveSupport::TestCase
self.use_transactional_tests = false
test "creation commits its index and unread writes once before enqueuing push" do
room = rooms(:designers)
sql = []
collect = ->(*, payload) { sql << payload[:sql] }
Room::PushMessageJob.expects(:perform_later).with do |queued_room, message|
assert_not Message.connection.transaction_open?
assert_equal room, queued_room
assert_equal "One native commit", Message.connection.select_value("SELECT body FROM message_search_index WHERE rowid = #{message.id}")
true
end
ActiveSupport::Notifications.subscribed(collect, "sql.active_record") do
room.messages.create!(creator: users(:david), body: "<p>One native commit</p>")
end
assert_equal 1, sql.count { |statement| statement.match?(/\ACOMMIT\b/i) }
commit = sql.index { |statement| statement.match?(/\ACOMMIT\b/i) }
assert_operator sql.index { |statement| statement.match?(/insert into message_search_index/i) }, :<, commit
assert_operator sql.index { |statement| statement.match?(/UPDATE "memberships"/i) }, :<, commit
end
end
-38
View File
@@ -1,38 +0,0 @@
require "test_helper"
class Message::MentioneeTest < ActiveSupport::TestCase
test "ordinary messages return an empty user relation without loading their room" do
message = rooms(:pets).messages.create!(creator: users(:jason), body: "<p>Fish &amp; chips 🌍</p>").reload
message.body
assert_not message.association(:room).loaded?
queries = []
subscriber = ->(*, payload) { queries << payload[:sql] unless payload[:name] == "SCHEMA" || payload[:cached] }
ActiveSupport::Notifications.subscribed(subscriber, "sql.active_record") do
mentionees = message.mentionees
assert_equal User, mentionees.klass
assert_empty mentionees.to_a
assert_empty mentionees.ids
assert_empty mentionees.active_bots.excluding(users(:jason)).to_a
end
assert_empty queries
assert_not message.association(:room).loaded?
assert_not message.changed?
end
test "signed mentions follow a moved message and fresh membership changes" do
message = rooms(:designers).messages.create!(creator: users(:david), body: "<p>Hey #{mention_attachment_for(:kevin)}</p>")
assert_equal [ users(:kevin).id ], message.reload.mentionees.ids
Message.where(id: message.id).update_all(room_id: rooms(:pets).id)
message = Message.find(message.id)
assert_empty message.mentionees.ids
membership = Membership.create!(room: rooms(:pets), user: users(:kevin))
assert_equal [ users(:kevin).id ], message.mentionees.ids
membership.delete
assert_empty message.mentionees.ids
assert_equal rooms(:pets).id, message.room_id
assert_not message.changed?
end
end
-22
View File
@@ -17,28 +17,6 @@ class MessageTest < ActiveSupport::TestCase
assert_not Message.new(body: "🔥 💯").plain_text_body.all_emoji?
end
test "plain text keeps Action Text formatting and attachment conversion" do
bodies = [ nil, "", "Hello &amp; goodbye", "<div>First<br>second</div>", "<p>One</p><p>Two</p>",
"<ul><li>First</li><li><strong>Second</strong></li></ul>", "<blockquote>Quoted</blockquote>",
"<pre><code>one\n two</code></pre>", "<table><tr><td>one</td><td>two</td></tr></table>",
"<div><em>Unclosed", "😄🤘", "<div>Hi #{mention_attachment_for(:david)}</div>" ]
bodies.each do |html|
message = Message.new(body: html)
assert_equal message.body.to_plain_text, message.plain_text_body, html.inspect
end
end
test "plain text follows body replacement and does not expose mutable cached text" do
message = Message.new(body: "<p>First</p>")
message.plain_text_body.replace("Caller edit")
assert_equal "First", message.plain_text_body
message.body = "<p>Replacement</p>"
assert_equal "Replacement", message.plain_text_body
message = messages(:first)
message.update!(body: "<p>Reloaded body</p>")
assert_equal message.body.to_plain_text, message.reload.plain_text_body
end
test "mentionees" do
message = Message.new room: rooms(:pets), body: "<div>Hey #{mention_attachment_for(:david)}</div>", creator: users(:jason), client_message_id: "earth"
assert_equal [ users(:david) ], message.mentionees
-96
View File
@@ -1,96 +0,0 @@
require "test_helper"
require "tmpdir"
class RecordCacheTest < ActiveSupport::TestCase
setup do
@directory = Dir.mktmpdir
@database = File.join(@directory, "records.sqlite3")
SQLite3::Database.new(@database) do |database|
database.execute("PRAGMA journal_mode=WAL")
database.execute("CREATE TABLE changes_for_test (value TEXT)")
end
ActiveRecord::Base.stubs(:connection_db_config).returns(Struct.new(:database).new(@database))
ActiveRecord::Base.connection.stubs(:transaction_open?).returns(false)
@cache = ResponseCache.new
@cache.stubs(:budget).returns(4096)
ResponseCache.stubs(:instance).returns(@cache)
FragmentCache.stubs(:store).returns(ActiveSupport::Cache::MemoryStore.new(size: 64.kilobytes))
@record = User.instantiate(users(:david).attributes_before_type_cast.merge(
"created_at" => "2026-10-08 12:34:56.123456", "updated_at" => "2026-10-08 12:34:57.654321",
"status" => 2, "role" => 1))
end
teardown do
@cache.clear
FileUtils.remove_entry(@directory)
end
test "hits reconstruct independent persisted models without losing raw timestamps or enum values" do
version = @cache.version
assert_same @record, RecordCache.fetch("user", version) { [ @record ] }.first
first = RecordCache.fetch("user", version) { flunk "cache hit queried records" }.first
second = RecordCache.fetch("user", version) { flunk "cache hit queried records" }.first
assert_not_same @record, first
assert_not_same first, second
assert first.persisted?
assert_not first.changed?
assert_equal @record.attributes_before_type_cast, first.attributes_before_type_cast
assert_equal 123456, first.created_at.usec
assert_equal 654321, first.updated_at.usec
assert_equal "banned", first.status
assert_equal "administrator", first.role
first.name = "Request-local edit"
assert_equal @record.name, second.name
assert_equal @record.name, RecordCache.fetch("user", version) { flunk "cache hit queried records" }.first.name
end
test "foreign commit makes an old captured epoch query fresh records instead of reusing a snapshot" do
version = @cache.version
RecordCache.fetch("user", version) { [ @record ] }
foreign_commit
fresh = User.instantiate(@record.attributes_before_type_cast.merge("name" => "Changed elsewhere"))
assert_same fresh, RecordCache.fetch("user", version) { [ fresh ] }.first
assert_same fresh, RecordCache.fetch("user", @cache.version) { [ fresh ] }.first
assert_equal fresh.name, RecordCache.fetch("user", @cache.version) { flunk "fresh snapshot missing" }.first.name
end
test "observer is rechecked after a snapshot lookup" do
version = @cache.version
RecordCache.fetch("user", version) { [ @record ] }
store = FragmentCache.store
original_read = store.method(:read)
commit = method(:foreign_commit)
store.define_singleton_method(:read) do |*arguments|
original_read.call(*arguments).tap { |snapshot| commit.call if snapshot }
end
fresh = User.instantiate(@record.attributes_before_type_cast.merge("name" => "Committed during lookup"))
assert_same fresh, RecordCache.fetch("user", version) { [ fresh ] }.first
end
test "a commit while loading records prevents admission under the old epoch" do
version = @cache.version
FragmentCache.store.expects(:write).never
assert_same @record, RecordCache.fetch("user", version) { foreign_commit; [ @record ] }.first
replacement = User.instantiate(@record.attributes_before_type_cast.merge("name" => "Fresh load"))
assert_same replacement, RecordCache.fetch("user", version) { [ replacement ] }.first
end
test "disabled budget missing epoch and open transactions always query and never admit" do
version = @cache.version
RecordCache.fetch("user", version) { [ @record ] }
FragmentCache.store.expects(:write).never
@cache.stubs(:budget).returns(0)
assert_same @record, RecordCache.fetch("user", version) { [ @record ] }.first
@cache.stubs(:budget).returns(4096)
assert_same @record, RecordCache.fetch("user", nil) { [ @record ] }.first
ActiveRecord::Base.connection.stubs(:transaction_open?).returns(true)
assert_same @record, RecordCache.fetch("user", version) { [ @record ] }.first
assert_same @record, RecordCache.fetch("transaction-only", version) { [ @record ] }.first
end
private
def foreign_commit
SQLite3::Database.new(@database) { |database| database.execute("INSERT INTO changes_for_test VALUES ('committed')") }
end
end
-88
View File
@@ -1,88 +0,0 @@
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
@@ -1,237 +0,0 @@
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
@@ -1,152 +0,0 @@
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
@@ -1,86 +0,0 @@
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
-10
View File
@@ -89,16 +89,6 @@ class Room::PushTest < ActiveSupport::TestCase
assert_includes pushed_users { post_to_designers_mentioning_kevin }, users(:kevin)
end
test "does not build notification payloads without recipients" do
Push::Subscription.delete_all
message = messages(:first)
message.expects(:plain_text_body).never
message.expects(:creator).never
Rails.configuration.x.web_push_pool.expects(:queue).never
Room::MessagePusher.new(room: message.room, message: message).push
end
private
def post_to_designers_mentioning_kevin
rooms(:designers).messages.create! body: "Hey #{mention_attachment_for(:kevin)}", client_message_id: "earth", creator: users(:david)
-14
View File
@@ -25,20 +25,6 @@ class RoomTest < ActiveSupport::TestCase
assert room.users.include?(users(:david))
end
test "a new message marks unread the members who had read the room and leaves the unread ones as they were" do
room = rooms(:watercooler)
reader = memberships(:jason_watercooler)
behind = memberships(:bender_watercooler)
behind.update_columns unread_at: 1.hour.ago, updated_at: 1.hour.ago
behind_before = behind.reload.attributes.slice("unread_at", "updated_at")
message = room.messages.create! creator: users(:david), body: "Hello", client_message_id: "unread-once"
assert_equal message.created_at, reader.reload.unread_at
assert_equal behind_before, behind.reload.attributes.slice("unread_at", "updated_at")
assert_not memberships(:david_watercooler).reload.unread?
end
test "type" do
assert Rooms::Open.new.open?
assert_not Rooms::Open.new.direct?
-20
View File
@@ -1,20 +0,0 @@
require "test_helper"
class SessionTest < ActiveSupport::TestCase
test "start a session" do
assert_difference -> { users(:david).sessions.count }, +1 do
users(:david).sessions.create!(user_agent: "This device", ip_address: "127.0.0.1")
end
end
test "a session doesn't start on a password checked before it changed" do
user = User.find(users(:david).id)
User.find(user.id).update!(password: "a new secret123456")
assert_no_difference -> { Session.count } do
assert_raises(ActiveRecord::RecordInvalid) do
user.sessions.create!(user_agent: "This device", ip_address: "127.0.0.1")
end
end
end
end
+1 -1
View File
@@ -51,7 +51,7 @@ class UserTest < ActiveSupport::TestCase
end
test "changing the password signs out every session, the current one included" do
Current.session = users(:david).sessions.create!(user_agent: "This device", ip_address: "127.0.0.1")
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")
+5 -3
View File
@@ -87,16 +87,18 @@ export function sockets() {
export function messages() {
const cookie = `session_token=${dummyCookies[0][0]}`;
const response = http.get(`http://${host}${port}/rooms/1`, { headers: { "Cookie": cookie }, responseType: "text" });
const csrfToken = response.body.match(/<meta name="csrf-token" content="([^"]*)"/i)[1];
const postHeaders = {
"Cookie": cookie,
"Sec-Fetch-Site": "same-origin",
"Origin": `http://${host}${port}`,
"Accept": "text/vnd.turbo-stream.html, text/html, application/xhtml+xml"
}
const payload = {
"message[body]": "Hello from k6",
"message[client_message_id]": Math.random().toString(36)
"message[client_message_id]": Math.random().toString(36),
"authenticity_token": csrfToken
};
for (let i = 0; i < 100; i++) {
-9
View File
@@ -33,13 +33,4 @@ 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
@@ -108,8 +108,8 @@ module PushServiceTestHelper
end
# Built as WebPush::PersistentRequest builds it for a delivery.
def pinned_connection(server, ip = IP, http_class: WebPush::Connections::HTTP)
http_class.new(HOST, server.port, nil).tap do |http|
def pinned_connection(server, ip = IP)
Net::HTTP.new(HOST, server.port, nil).tap do |http|
http.ipaddr = ip
http.use_ssl = true
end
-2
View File
@@ -22,8 +22,6 @@ 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)