Compare commits

..
Author SHA1 Message Date
Jeremy Daer 5b28b3106a Build only into once-campfire-edge; release-images promotes releases
This repository's token now writes only the edge package. basecamp/release-images copies an approved edge digest into the release package, so the release environment and the publish token are no longer used here.
2026-10-07 22:55:31 -07:00
Jeremy Daer 9ea475a386 Publish main builds to once-campfire-edge and releases from the release environment 2026-10-07 18:31:03 -07:00
97 changed files with 226 additions and 2661 deletions

No files matched your search

+5 -5
View File
@@ -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
@@ -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:
+5 -13
View File
@@ -41,7 +41,8 @@ jobs:
arch: arm64
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
# Everything goes to <repo>-edge, with this repository's own token.
IMAGE_NAME: ${{ github.repository }}-edge
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -87,9 +88,6 @@ jobs:
type=ref,event=branch
type=ref,event=tag
type=sha,format=short,prefix=sha-
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
flavor: |
latest=false
suffix=-${{ matrix.arch }}
@@ -128,6 +126,8 @@ jobs:
name: Create multi-arch manifest and sign
needs: build
if: github.event_name != 'pull_request'
# Multi-platform tags, signed, in <repo>-edge only. basecamp/release-images
# promotes v* tags from there into the release package after a reviewer approves.
runs-on: ubuntu-latest
permissions:
contents: read
@@ -136,7 +136,7 @@ jobs:
timeout-minutes: 20
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
IMAGE_NAME: ${{ github.repository }}-edge
steps:
- name: Set up Docker Buildx (for imagetools)
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
@@ -166,10 +166,6 @@ jobs:
type=ref,event=branch
type=ref,event=tag
type=sha,format=short,prefix=sha-
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
flavor: |
latest=false
labels: |
@@ -188,10 +184,6 @@ jobs:
[ -z "$tag" ] && continue
echo "Creating manifest for $tag"
src_tag="$tag"
if [[ "$tag" == *:latest && "${GITHUB_REF}" == refs/tags/* ]]; then
ref="${GITHUB_REF#refs/tags/}"
src_tag="${tag%:latest}:$ref"
fi
if [ -n "${IMAGE_DESCRIPTION:-}" ]; then
docker buildx imagetools create \
--tag "$tag" \
+1 -1
View File
@@ -1 +1 @@
4.0.9
3.4.10
+1 -13
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
@@ -13,18 +13,6 @@ RUN apt-get update -qq && \
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
rm -rf /var/lib/apt/lists /var/cache/apt/archive
# Fail the build if either of these withdrawn roots is still where Ruby's OpenSSL looks for
# trusted certificates
RUN ruby -ropenssl -e ' \
file = ENV[OpenSSL::X509::DEFAULT_CERT_FILE_ENV] || OpenSSL::X509::DEFAULT_CERT_FILE; \
dir = ENV[OpenSSL::X509::DEFAULT_CERT_DIR_ENV] || OpenSSL::X509::DEFAULT_CERT_DIR; \
pems = [file, *Dir.glob(File.join(dir, "*"))].select { |path| File.file?(path) } \
.flat_map { |path| File.read(path).scan(/-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/m) }; \
subjects = pems.map { |pem| OpenSSL::X509::Certificate.new(pem).subject.to_s }.uniq; \
abort "Only #{subjects.size} roots in #{file} and #{dir}" if subjects.size < 100; \
withdrawn = subjects.grep(%r{/CN=(emSign ECC Root CA - G3|AAA Certificate Services)\z}); \
abort "Withdrawn roots still trusted: #{withdrawn}" if withdrawn.any?'
# Set production environment
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
+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"
+21 -21
View File
@@ -146,7 +146,7 @@ GEM
bcrypt (3.1.22)
benchmark (0.5.0)
bigdecimal (4.1.3)
brakeman (8.1.0)
brakeman (8.0.6)
racc
builder (3.3.0)
bundler-audit (0.9.3)
@@ -167,9 +167,9 @@ GEM
crack (1.0.0)
bigdecimal
rexml
crass (1.0.8)
crass (1.0.7)
date (3.5.1)
debug (1.11.1)
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
drb (2.2.3)
@@ -270,13 +270,13 @@ GEM
useragent (~> 0.16.3)
pp (0.6.4)
prettyprint
prettyprint (0.2.1)
prettyprint (0.2.0)
prism (1.9.0)
psych (5.5.0)
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)
@@ -390,10 +390,10 @@ GEM
rack-protection (= 4.2.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)
sqlite3 (2.10.2-aarch64-linux-gnu)
sqlite3 (2.10.2-arm64-darwin)
sqlite3 (2.10.2-x86_64-darwin)
sqlite3 (2.10.2-x86_64-linux-gnu)
sqlite3 (2.9.6-aarch64-linux-gnu)
sqlite3 (2.9.6-arm64-darwin)
sqlite3 (2.9.6-x86_64-darwin)
sqlite3 (2.9.6-x86_64-linux-gnu)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
@@ -452,7 +452,7 @@ DEPENDENCIES
ostruct
platform_agent
propshaft!
puma (~> 8.0)
puma (~> 7.2, >= 7.2.1)
rails!
rails_autolink
redis (~> 5.4)
+6 -13
View File
@@ -51,7 +51,7 @@ If you'd rather run the Docker image yourself, you can read more about that in t
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.
and room access; commits from any SQLite writer invalidate pages, and CSRF masks stay fresh.
Native HTML, JSON and stream fragments have a separate 64 MiB memory limit per worker;
shared rate limits retain their existing store.
@@ -61,14 +61,11 @@ Campfire also has implementations in Django, Laravel, Express, Elixir, Go, Rust
| 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 | 710 | 414 | 1,696 | 42,481 | 1,126 | 52,512 | 105,909 | 137,505 |
| Messages page | 1,113 | 454 | 1,890 | 74,779 | 1,407 | 54,100 | 103,301 | 142,669 |
| Sidebar | 1,901 | 576 | 3,364 | 94,460 | 3,621 | 58,714 | 120,930 | 151,001 |
| Search | 1,332 | 549 | 2,615 | 83,493 | 2,127 | 60,444 | 121,502 | 148,766 |
| Post a message | 226 | 113 | 567 | 2,121 | 1,392 | 9,000 | 8,004 | 7,486 |
[Shared verification](https://github.com/basecamp/once-campfire-verification) · [Detailed results](https://github.com/basecamp/once-campfire-verification/blob/main/docs/performance-review.md).
@@ -83,7 +80,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, CachedResponses
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
+32 -36
View File
@@ -1,16 +1,15 @@
require "zlib"
module CachedResponses
extend ActiveSupport::Concern
CACHE_HEADERS = %w[ content-type content-encoding cache-control etag last-modified vary ].freeze
CACHE_HEADERS = %w[ content-type cache-control etag last-modified vary ].freeze
CSRF_TAG = /<meta\b[^>]*\bname="csrf-token"[^>]*>|<input\b[^>]*\bname="authenticity_token"[^>]*>/
included do
prepend_before_action :capture_response_cache_version
end
def perform_caching
return false unless super && @response_cache_version.present? && !FragmentCache.transaction_open?
return false unless super && @response_cache_version.present? && !ActiveRecord::Base.connection.transaction_open?
# Check again after authentication, before the first native fragment lookup.
# Later renders retain this immutable namespace and cannot poison a new epoch.
@@ -26,19 +25,15 @@ module CachedResponses
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)
@fragment_cache_namespace ||= [
@response_cache_version, request.base_url, request.script_name, request.format.to_s, I18n.locale,
Current.user&.id, (Digest::SHA256.hexdigest(Current.session.token) if Current.session),
(Digest::SHA256.hexdigest(real_csrf_token) if request.format.html? || request.format.turbo_stream?)
].freeze
version = @response_cache_version unless Array(key).flatten.any? { |part| part.is_a?(FragmentCache::ContentKey) }
super([ version, @fragment_cache_context, key ])
super([ @fragment_cache_namespace, 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.
@@ -48,10 +43,10 @@ module CachedResponses
# 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
token = form_authenticity_token
key = response_cache_key
original_session = session.to_hash.deep_dup
key = response_cache_key(encoding)
return yield if key.bytesize > ResponseCache::MAX_KEY_BYTES
entry = ResponseCache.instance.read(key, @response_cache_version)
@@ -60,18 +55,16 @@ module CachedResponses
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)
cache_completed_response(key, original_session)
end
end
end
if entry
response.headers.merge!(entry[:headers])
response.headers.delete("Content-Length")
self.response_body = entry[:body]
self.response_body = entry[:body].gsub(entry[:marker], token)
elsif !rendered
# A queued request retains its pre-auth snapshot. If it has expired,
# render outside the stripe instead of blocking the next generation.
@@ -82,19 +75,14 @@ module CachedResponses
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(", ")
def cache_completed_response(key, original_session)
if response.status == 200 && response.media_type == "text/html" && session.to_hash == original_session
marker = "campfire-csrf-#{SecureRandom.hex(32)}"
# Replace only framework token attributes, never a matching token in
# message text. Postprocessing also leaves fragment caches untouched.
body = csrf_neutral_body(response.body, marker)
headers = response.headers.slice(*CACHE_HEADERS).to_h.freeze
entry = { body: body.freeze, headers: headers }.freeze
ResponseCache.instance.write(key, @response_cache_version, entry)
entry
ResponseCache.instance.write(key, @response_cache_version, { body: body.freeze, marker: marker.freeze, headers: headers }.freeze)
end
end
@@ -103,16 +91,24 @@ module CachedResponses
!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?
!ActiveRecord::Base.connection.transaction_open?
end
def response_cache_key(encoding)
def response_cache_key
ActiveSupport::JSON.encode([
controller_path, request.fullpath, request.base_url, request.user_agent, encoding,
controller_path, request.fullpath, request.base_url, request.user_agent,
request.headers["Accept"], request.headers["Turbo-Frame"], I18n.locale,
# Old token-bearing installation cookies remain valid without affecting HTML.
# Tokens are hydrated per request, including clients that replay an old
# cookie. Their raw CSRF secret does not select a presentation variant.
Current.user.id, Current.session.token, session.to_hash.except("_csrf_token"),
cookies.to_h.except("_campfire_session", "session_token")
])
end
def csrf_neutral_body(body, marker)
body.gsub(CSRF_TAG) do |tag|
attribute = tag.start_with?("<meta") ? "content" : "value"
tag.sub(/\b#{attribute}="[^"]*"/, %(#{attribute}="#{marker}"))
end
end
end
@@ -1,69 +0,0 @@
# Allows the scripts the importmap helper emits and nothing else: the inline
# importmap and its entry-point module by content hash, and the asset
# directory its URLs point into. Not the whole origin, because uploads are
# served from this origin too. The hashes change only when the importmap does
# (a deploy), so cached HTML stays valid, unlike a per-request nonce.
module InlineScriptHashes
extend ActiveSupport::Concern
ENTRY_POINT_SCRIPT = %(import "application")
included do
# With the policy turned off there's nothing to add to, so leave the
# request without one rather than starting an empty policy
content_security_policy if: -> { request.content_security_policy } do |policy|
if sources = policy.directives["script-src"]
importmap = Rails.application.importmap.to_json(resolver: helpers)
policy.directives["script-src"] = (sources - [ "'self'" ]) | script_sources(importmap)
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
# is allowed up to its asset directory, the last one in its path, since an
# asset host's own path may contain one too; a URL outside it is allowed
# exactly.
#
# This origin's own sources leave out the scheme, so they match the page's
# scheme even behind a proxy that terminates TLS without saying so.
#
# A path only limits the first request: browsers check just the origin
# after a redirect. Nothing under the asset directory redirects.
def script_locations(importmap)
assets = File.join(config.relative_url_root.to_s, Rails.application.config.assets.prefix, "")
JSON.parse(importmap)["imports"].values.map do |url|
url = URI.join(request.base_url, url)
origin = url.origin == request.base_url ? request.host_with_port : url.origin
directory = url.path[0, url.path.rindex(assets) + assets.length] if url.path.include?(assets)
"#{origin}#{directory || url.path}"
end.uniq
end
def script_hashes(*scripts)
scripts.map { |script| "'sha256-#{Digest::SHA256.base64digest(script)}'" }
end
end
+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
+3 -4
View File
@@ -14,7 +14,8 @@ class MessagesController < ApplicationController
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
# Masked CSRF tokens remain fresh while the presentation validator stays stable.
fresh_when etag: Digest::SHA256.hexdigest(csrf_neutral_body(body, "")), template: false
unless performed?
response.content_type = "text/html"
self.response_body = body
@@ -28,9 +29,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 -4
View File
@@ -21,10 +21,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"
@@ -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
+2 -38
View File
@@ -1,26 +1,14 @@
class Users::ProfilesController < ApplicationController
before_action :set_user
# Limited like signing in, so the current password can't be guessed here instead.
rate_limit to: 10, within: 3.minutes, only: :update, if: :answering_password_challenge?,
with: -> { redirect_to user_profile_url, alert: "Too many attempts. Try again in a few minutes." }
def show
@direct_memberships, @shared_memberships =
Current.user.memberships.with_ordered_room.partition { |m| m.room.direct? }
end
def update
if @user.update user_params
start_new_session_for @user if @user.saved_change_to_password_digest?
redirect_to user_profile_url, notice: update_notice
else
redirect_to user_profile_url, alert: update_alert
end
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
@user.update user_params
redirect_to user_profile_url, notice: update_notice
end
private
@@ -28,35 +16,11 @@ class Users::ProfilesController < ApplicationController
@user = Current.user
end
# Always with the challenge, so the model checks it whenever the save would change the password, however the
# request spelled the new one.
def user_params
params.require(:user).permit(:name, :avatar, :email_address, :password, :bio).compact
.merge(password_challenge: password_challenge)
end
# Only a string is a challenge, so the rate limit counts exactly what the model checks.
def password_challenge
challenge = params.dig(:user, :password_challenge)
challenge.is_a?(String) ? challenge : ""
end
# Guessing the current password means sending one, so this is what the rate limit counts.
def answering_password_challenge?
!password_challenge.empty?
end
def update_notice
params[:user][:avatar] ? "It may take up to 30 minutes to change everywhere." : "✓"
end
def update_alert
if !@user.errors.include?(:password_challenge)
@user.errors.full_messages.to_sentence
elsif password_challenge.empty?
"Enter your current password to change it."
else
"That isn't your current password."
end
end
end
@@ -5,14 +5,17 @@ class Users::PushSubscriptionsController < ApplicationController
end
def create
subscription = @push_subscriptions.find_or_initialize_by(push_subscription_params)
subscription.user_agent = request.user_agent if subscription.new_record?
# Existing endpoints must pass current validations too
if subscription.valid?
head register_while_signed_in(subscription) ? :ok : :unauthorized
if subscription = @push_subscriptions.find_by(push_subscription_params)
# Existing endpoints must pass current validations
if subscription.valid?
subscription.touch
head :ok
else
head :unprocessable_entity
end
else
head :unprocessable_entity
subscription = @push_subscriptions.create push_subscription_params.merge(user_agent: request.user_agent)
head subscription.persisted? ? :ok : :unprocessable_entity
end
end
@@ -26,24 +29,6 @@ class Users::PushSubscriptionsController < ApplicationController
@push_subscriptions = Current.user.push_subscriptions
end
# A password change ends every session and subscription. A request that authenticated just before it mustn't
# put one back, so the session is checked under the same write lock as the save, and the endpoint is looked up
# again there, past the query cache, so two tabs posting at once register it once. Validation, which resolves
# the endpoint's address, runs before taking the lock.
def register_while_signed_in(subscription)
Push::Subscription.uncached do
Push::Subscription.transaction do
if Session.exists?(Current.session.id)
if existing = @push_subscriptions.find_by(push_subscription_params)
existing.touch
else
subscription.save!(validate: false)
end
end
end
end
end
def push_subscription_params
params.require(:push_subscription).permit(:endpoint, :p256dh_key, :auth_key)
end
-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 -1
View File
@@ -5,7 +5,7 @@ module ContentFilters
#
# * SanitizeTags strips disallowed markup from the message body
# * Action Text sanitizes the rendered content (lib/rails_ext/action_text_allowed_tags.rb)
# * the final html is re-sanitized before its text is linked (MessagesHelper#auto_link_text)
# * auto_link re-sanitizes the final html (MessagesHelper#message_presentation)
EDITOR_FORMATTING_TAGS = %w[ s u mark table thead tbody tfoot tr th td ]
EDITOR_FORMATTING_ATTRIBUTES = %w[ data-language ]
+4 -64
View File
@@ -1,12 +1,9 @@
module MessagesHelper
# Rendered text is re-sanitized before linking. Rails' default safe list
# lacks the formatting the rich text editor produces
# auto_link re-sanitizes with Rails' default safe list, which lacks the
# formatting the rich text editor produces
AUTO_LINK_ALLOWED_TAGS = Rails::HTML5::SafeListSanitizer.allowed_tags + ContentFilters::EDITOR_FORMATTING_TAGS
AUTO_LINK_ALLOWED_ATTRIBUTES = Rails::HTML5::SafeListSanitizer.allowed_attributes + ContentFilters::EDITOR_FORMATTING_ATTRIBUTES
# Text auto_link could turn into a link: a URL has a scheme or www., an email address an @
LINKABLE_TEXT = /:|@|www\./i
def message_area_tag(room, &)
tag.div id: "message-area", class: "message-area", contents: true, data: {
controller: "messages presence drop-target",
@@ -66,7 +63,8 @@ module MessagesHelper
when "sound"
message_sound_presentation(message)
else
text_message_presentation(message.body.body)
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)),
html: { target: "_blank" }, sanitize_options: { tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES }
end
rescue Exception => e
Sentry.capture_exception(e, extra: { message: message })
@@ -75,65 +73,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.
def auto_link_text(html)
fragment = Nokogiri::HTML5.fragment(sanitize(html, tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES))
fragment.xpath(".//text()[not(ancestor::a)]").each do |node|
if node.text.match?(LINKABLE_TEXT)
text = node.to_html
linked = auto_link(text, html: { target: "_blank" })
node.replace Nokogiri::HTML5.fragment(linked) unless linked == text
end
end
# A <pre> starting with a newline needs an extra one written out to survive the next parse
fragment.to_html(preserve_newline: true).html_safe
end
def messages_actions
"turbo:before-stream-render@document->messages#beforeStreamRender keydown.up@document->messages#editMyLastMessage"
end
-1
View File
@@ -2,7 +2,6 @@ module TranslationsHelper
TRANSLATIONS = {
email_address: { "🇺🇸": "Enter your email address", "🇪🇸": "Introduce tu correo electrónico", "🇫🇷": "Entrez votre adresse courriel", "🇮🇳": "अपना ईमेल पता दर्ज करें", "🇩🇪": "Geben Sie Ihre E-Mail-Adresse ein", "🇧🇷": "Insira seu endereço de email", "🇯🇵": "メールアドレスを入力してください" },
password: { "🇺🇸": "Enter your password", "🇪🇸": "Introduce tu contraseña", "🇫🇷": "Saisissez votre mot de passe", "🇮🇳": "अपना पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr Passwort ein", "🇧🇷": "Insira sua senha", "🇯🇵": "パスワードを入力してください" },
current_password: { "🇺🇸": "Enter your current password", "🇪🇸": "Introduce tu contraseña actual", "🇫🇷": "Saisissez votre mot de passe actuel", "🇮🇳": "अपना वर्तमान पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr aktuelles Passwort ein", "🇧🇷": "Insira sua senha atual", "🇯🇵": "現在のパスワードを入力してください" },
update_password: { "🇺🇸": "Change password", "🇪🇸": "Cambiar contraseña", "🇫🇷": "Changer le mot de passe", "🇮🇳": "पासवर्ड बदलें", "🇩🇪": "Passwort ändern", "🇧🇷": "Alterar senha", "🇯🇵": "パスワードを変更" },
user_name: { "🇺🇸": "Enter your name", "🇪🇸": "Introduce tu nombre", "🇫🇷": "Entrez votre nom", "🇮🇳": "अपना नाम दर्ज करें", "🇩🇪": "Geben Sie Ihren Namen ein", "🇧🇷": "Insira seu nome", "🇯🇵": "お名前を入力してください" },
account_name: { "🇺🇸": "Name this account", "🇪🇸": "Nombre de esta cuenta", "🇫🇷": "Nommez ce compte", "🇮🇳": "इस खाते का नाम दें", "🇩🇪": "Benennen Sie dieses Konto", "🇧🇷": "Dê um nome a essa conta", "🇯🇵": "アカウントに名前を付ける" },
@@ -1,7 +0,0 @@
module Users::PushSubscriptionsHelper
# Names the session without revealing its token, so the browser can tell when it's in a new one. The creation
# time keeps an id reused by a new database from looking like the same session.
def push_subscription_session_marker
Digest::SHA256.hexdigest("push-subscription-session-#{Current.session.id}-#{Current.session.created_at.to_f}") if Current.session
end
end
@@ -106,8 +106,7 @@ export default class extends Controller {
async #syncPushSubscription(subscription) {
const response = await post(this.subscriptionsUrlValue, { body: this.#extractJsonPayloadAsString(subscription), responseKind: "turbo-stream" })
// Only a refused endpoint is for good. A session signed out mid-request posts it again after signing in.
if (response.unprocessableEntity) subscription.unsubscribe()
if (!response.ok) subscription.unsubscribe()
}
async #requestPermissionAndSubscribe(registration) {
@@ -1,32 +0,0 @@
import { Controller } from "@hotwired/stimulus"
import { post } from "@rails/request.js"
const SYNCED_SESSION_KEY = "push-subscription-synced-session"
// A password change removes the account's push subscriptions, the one this browser holds included. Post it again
// once per session, so a browser that's still signed in keeps getting notifications. A failure here leaves the
// browser's own subscription alone: the next session tries again.
export default class extends Controller {
static values = { url: String, session: String }
async connect() {
if (this.#allowed && this.sessionValue && localStorage.getItem(SYNCED_SESSION_KEY) !== this.sessionValue) {
const registration = await navigator.serviceWorker.getRegistration(window.location.origin)
const subscription = await registration?.pushManager?.getSubscription()
if (Notification.permission === "granted" && subscription) {
await post(this.urlValue, { body: this.#payload(subscription), responseKind: "turbo-stream" })
localStorage.setItem(SYNCED_SESSION_KEY, this.sessionValue)
}
}
}
get #allowed() {
return navigator.serviceWorker && window.Notification
}
#payload(subscription) {
const { endpoint, keys: { p256dh, auth } } = subscription.toJSON()
return JSON.stringify({ push_subscription: { endpoint, p256dh_key: p256dh, auth_key: auth } })
}
}
@@ -28,7 +28,7 @@ export default class extends Controller {
}
#stripMentionAttachments(node) {
node.querySelectorAll(".mention").forEach(mention => mention.replaceWith(mention.textContent.trim()))
node.querySelectorAll(".mention").forEach(mention => mention.outerHTML = mention.textContent.trim())
return node
}
+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) => {
-11
View File
@@ -1,20 +1,9 @@
# 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
+1 -4
View File
@@ -23,14 +23,11 @@ class Opengraph::Fetch
end
private
# The nil proxy address ignores http_proxy: a proxy would look the host up again, and the connection
# would no longer go to the checked IP.
#
# The timeouts bound each operation. A host can still send its headers or body a byte at a time,
# in as many reads as it likes: UnfurlLinksController puts the whole unfurl under one deadline.
def request(url, request_class, ip:)
MAX_REDIRECTS.times do
Net::HTTP.start(url.host, url.port, nil, ipaddr: ip, use_ssl: url.scheme == "https", **timeouts) do |http|
Net::HTTP.start(url.host, url.port, ipaddr: ip, use_ssl: url.scheme == "https", **timeouts) do |http|
http.request request_class.new(url) do |response|
if response.is_a?(Net::HTTPRedirection)
url, ip = resolve_redirect(response["location"])
-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
+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
+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
-27
View File
@@ -18,10 +18,8 @@ class User < ApplicationRecord
enum :status, %i[ active deactivated banned ], default: :active
has_secure_password validations: false
validate :password_challenge_matches, if: :will_save_change_to_password_digest?, unless: -> { password_challenge.nil? }
after_create_commit :grant_membership_to_open_rooms
after_update :sign_out_all_sessions, if: :saved_change_to_password_digest?
scope :ordered, -> { order("LOWER(name)") }
scope :filtered_by, ->(query) { where("name like ?", "%#{query}%") }
@@ -52,31 +50,6 @@ class User < ApplicationRecord
end
private
# The profile always sets password_challenge, and it's checked whenever the save would change the password; scripts
# that reset one don't set it.
def password_challenge_matches
unless password_digest_was.present? && BCrypt::Password.new(password_digest_was).is_password?(password_challenge)
errors.add :password_challenge, :invalid
end
end
# A new password is how someone shuts out whoever else got into their account, so it ends every session, copies
# of the changing browser's cookie included, and every push subscription; browsers still signed in post theirs
# again. Connections close once that commits, registered on the transaction because a later save in it would
# replace the saved change an after_commit condition reads.
def sign_out_all_sessions
sessions.delete_all
push_subscriptions.delete_all
self.class.current_transaction.after_commit { disconnect_signed_out_sessions }
end
# The password is saved by now, so a realtime service that can't be reached mustn't fail the change.
def disconnect_signed_out_sessions
reset_remote_connections
rescue => error
Rails.logger.warn "Could not disconnect remote connections after a password change: #{error.class}"
end
# One statement, which SQLite runs under the write lock, so no room is closed between reading the open ones and
# inserting. A room that Room#destroy_later closes is either closed before, and skipped, or after, and takes this
# membership away with the others. A room that granted itself to every user when it was created is skipped too,
+2 -9
View File
@@ -3,20 +3,13 @@ module User::Transferable
TRANSFER_LINK_EXPIRY_DURATION = 4.hours
included do
# Bound to the password, so changing it also ends sign-in links made before.
generates_token_for :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION do
password_salt&.last(10)
end
end
class_methods do
def find_by_transfer_id(id)
find_by_token_for(:transfer, id)
find_signed(id, purpose: :transfer)
end
end
def transfer_id
generate_token_for(:transfer)
signed_id(purpose: :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION)
end
end
+1 -5
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 %>
@@ -62,11 +63,6 @@
<%= render "layouts/lightbox" %>
<% if Current.session %>
<%= tag.div hidden: true, data: { controller: "push-subscription-sync",
push_subscription_sync_url_value: user_push_subscriptions_path, push_subscription_sync_session_value: push_subscription_session_marker } %>
<% end %>
<a href="https://once.com" id="app-logo" target="_blank" aria-label="Once software from 37signals home page">
<%= image_tag "campfire-icon.png", alt: "Campfire logo", width: 256, height: 216 %>
</a>
+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-v6" ] 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>
-10
View File
@@ -69,16 +69,6 @@
</label>
</div>
<div class="flex align-center gap">
<%= translation_button(:current_password) %>
<label class="flex align-center gap flex-item-grow input input--actor">
<%= form.password_field :password_challenge, class: "input txt-large", autocomplete: "current-password",
placeholder: "Current password", required: false, maxlength: 72 %>
<%= image_tag "password.svg", aria: { hidden: "true" }, size: 24, class: "colorize--black" %>
</label>
</div>
<div class="flex align-center gap">
<%= translation_button(:update_password) %>
-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
+23 -19
View File
@@ -1,21 +1,25 @@
# Enforcing baseline policy. Application pages narrow script-src from this
# origin to its asset directory, since uploads are served from this origin
# too, and allow the two inline scripts the importmap helper emits by hash
# (InlineScriptHashes). There is no per-request nonce, so rendered HTML stays
# cacheable. 'self' remains for responses outside ApplicationController, none
# of which render user content.
# Be sure to restart your server when you modify this file.
# Define an application-wide content security policy.
# See the Securing Rails Applications Guide for more information:
# https://guides.rubyonrails.org/security.html#content-security-policy-header
# Rails.application.configure do
# config.content_security_policy do |policy|
# policy.default_src :self, :https
# policy.font_src :self, :https, :data
# policy.img_src :self, :https, :data
# policy.object_src :none
# policy.script_src :self, :https
# policy.style_src :self, :https
# # Specify URI for violation reports
# # policy.report_uri "/csp-violation-report-endpoint"
# end
#
# Nothing else is restricted: images, media, fonts, styles (including an
# account's custom styles) and connections behave as before.
# # Generate session nonces for permitted importmap and inline scripts
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
# config.content_security_policy_nonce_directives = %w(script-src)
#
# DISABLE_CONTENT_SECURITY_POLICY=true is the escape hatch for an install whose
# proxy or customization injects scripts the policy would block.
unless ENV["DISABLE_CONTENT_SECURITY_POLICY"] == "true"
Rails.application.configure do
config.content_security_policy do |policy|
policy.script_src :self
policy.object_src :none
policy.base_uri :self
end
end
end
# # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true
# end
+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,34 +0,0 @@
# Uploaded files are served from this origin. Application pages only allow
# scripts from the asset directory (InlineScriptHashes), so an upload can't
# load as a script whatever type it's served with. This guard is the second
# layer: browsers accept sixteen spellings of a JavaScript type, and Disk URLs
# sign the type they were minted with, so instead of refusing a list of types,
# anything Active Storage serves that isn't plainly media goes out as a
# download, whatever its URL says. A multi-range response keeps its
# multipart/byteranges type and boundary, which no browser runs as a script.
# Only reads are touched: uploading (direct upload JSON, Disk PUT) answers in
# its own types.
#
# A cache that stored an upload under a script type before this shipped can
# still serve it that way until the entry expires (an hour for Disk URLs, and
# never for blob proxy URLs, which Rails caches as immutable). The policy's
# path restriction is what keeps such a copy from running.
class UploadedScriptGuard
SERVABLE = %r{\A(image|video|audio)/|\A(application/pdf|application/octet-stream|text/plain|multipart/byteranges)\z}
def initialize(app)
@app = app
end
def call(env)
status, headers, body = @app.call(env)
if env["REQUEST_METHOD"].in?(%w[ GET HEAD ]) && env["PATH_INFO"].start_with?("#{ActiveStorage.routes_prefix}/") && (type = headers["content-type"])
headers["content-type"] = ActiveStorage.binary_content_type unless type.split(";").first.strip.downcase.match?(SERVABLE)
end
[ status, headers, body ]
end
end
Rails.application.config.middleware.use UploadedScriptGuard
+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
-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
+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
+17 -69
View File
@@ -14,7 +14,7 @@ class CachedResponsesTest < ActionDispatch::IntegrationTest
Rails.cache = ActiveSupport::Cache::MemoryStore.new
ActionController::Base.perform_caching = true
@room = rooms(:watercooler)
# Establish last-room cookies before checking reuse.
# Establish last-room and CSRF cookies before checking reuse.
2.times { get room_url(@room) }
ResponseCache.instance.clear
end
@@ -26,18 +26,6 @@ class CachedResponsesTest < ActionDispatch::IntegrationTest
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|
@@ -60,54 +48,37 @@ class CachedResponsesTest < ActionDispatch::IntegrationTest
assert_response :success
end
test "clients without token state reuse complete HTML and post without tokens" do
test "clients without a persisted CSRF session still reuse token-neutral HTML" 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
first = css_select('meta[name="csrf-token"]').first["content"]
cookies["_campfire_session"] = @login_cookie
ResponseCache.instance.expects(:write).never
get room_url(@room)
second = css_select('meta[name="csrf-token"]').first["content"]
assert_response :success
assert_equal first, response.body
assert_not_equal first, second
assert_no_match /campfire-csrf-/, 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" }
authenticity_token: second, message: { body: "fresh replay token works", client_message_id: "cache-replay-token" } }
assert_response :success
end
test "literal token-like text survives complete page reuse" do
literal = "campfire-csrf-literal authenticity_token csrf-token"
test "cached tokens stay fresh and literal token-like text survives" do
get room_url(@room)
literal = css_select('meta[name="csrf-token"]').first["content"]
@room.messages.create!(creator: users(:david), body: "literal #{literal}")
get room_url(@room)
first = response.body
first = css_select('meta[name="csrf-token"]').first["content"]
get room_url(@room)
assert_equal first, response.body
second = css_select('meta[name="csrf-token"]').first["content"]
assert_not_equal first, second
assert_includes response.body, "literal #{literal}"
end
assert_no_match /campfire-csrf-/, response.body
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"]
post room_messages_url(@room, format: :turbo_stream), params: {
authenticity_token: second, message: { body: "cached token works", client_message_id: "cache-token" } }
assert_response :success
end
test "local and foreign commits invalidate pages and nested fragments" do
@@ -136,29 +107,6 @@ class CachedResponsesTest < ActionDispatch::IntegrationTest
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"]
-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
-26
View File
@@ -63,32 +63,6 @@ class FragmentRenderingTest < ActionDispatch::IntegrationTest
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)
@@ -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
@@ -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
@@ -19,30 +19,4 @@ class Sessions::TransfersControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to root_url
assert parsed_cookies.signed[:session_token]
end
test "update rejects a link made before the password changed" do
user = users(:david)
transfer_id = user.transfer_id
user.update!(password: "a new secret123456")
put session_transfer_url(transfer_id)
assert_response :bad_request
assert_nil parsed_cookies.signed[:session_token]
end
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" }
@@ -27,190 +27,9 @@ class Users::ProfilesControllerTest < ActionDispatch::IntegrationTest
assert_equal "david@37signals.com", users(:david).email_address
end
test "changing the password signs out the account's other devices but not this browser" do
other_device = open_session
other_device.post session_url, params: { email_address: users(:david).email_address, password: "secret123456" }
other_device.get user_profile_url
other_device.assert_response :success
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_redirected_to user_profile_url
other_device.get user_profile_url
other_device.assert_redirected_to new_session_url
get user_profile_url
assert_response :success
assert_equal 1, users(:david).sessions.count
end
test "changing the password 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]
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_not_equal copied_cookie, cookies[:session_token]
copy = open_session
copy.cookies[:session_token] = copied_cookie
copy.get user_profile_url
copy.assert_redirected_to new_session_url
end
test "changing the password along with the avatar still signs this browser back in and closes live connections" do
remote_connections = mock
remote_connections.expects(:disconnect).with(reconnect: true)
ActionCable.server.stubs(:remote_connections).returns(mock.tap { |m| m.expects(:where).with(current_user: users(:david)).returns(remote_connections) })
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456", avatar: fixture_file_upload("moon.jpg", "image/jpeg") } }
assert_redirected_to user_profile_url
assert users(:david).reload.avatar.attached?
get user_profile_url
assert_response :success
end
test "changing the password closes the account's live connections" do
remote_connections = mock
remote_connections.expects(:disconnect).with(reconnect: true)
ActionCable.server.stubs(:remote_connections).returns(mock.tap { |m| m.expects(:where).with(current_user: users(:david)).returns(remote_connections) })
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_redirected_to user_profile_url
end
test "changing the password still signs out other sessions when the realtime service is unreachable" do
other_session = users(:david).sessions.first
ActionCable.server.stubs(:remote_connections).raises(RuntimeError.new("cable down"))
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_redirected_to user_profile_url
assert_not Session.exists?(other_session.id)
end
test "updating the profile without a new password leaves other sessions signed in" do
ActionCable.server.expects(:remote_connections).never
assert_no_difference -> { users(:david).sessions.count } do
put user_profile_url, params: { user: { name: "David", password: "" } }
end
assert_redirected_to user_profile_url
end
test "changing the password with the current one" do
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_redirected_to user_profile_url
assert_equal "✓", flash[:notice]
assert users(:david).reload.authenticate("a new secret123456")
end
test "changing the password without the current one changes nothing" do
assert_password_change_refused "Enter your current password to change it.",
user: { name: "Someone else", password: "a new secret123456" }
end
test "changing the password with a wrong current one changes nothing" do
assert_password_change_refused "That isn't your current password.",
user: { name: "Someone else", password: "a new secret123456", password_challenge: "not my password" }
end
test "a new password needs the current one however the form spells it" do
assert_password_change_refused "Enter your current password to change it.",
user: { name: "Someone else", "password(1)" => "a new secret123456" }
end
test "editing the profile with an empty current password doesn't need it" do
put user_profile_url, params: { user: { name: "Dave", password: "", password_challenge: "" } }
assert_redirected_to user_profile_url
assert_equal "Dave", users(:david).reload.name
end
test "editing the profile without changing the password doesn't need the current one" do
put user_profile_url, params: { user: { name: "Dave", bio: "Acrobat", password: "" } }
assert_redirected_to user_profile_url
assert_equal "Dave", users(:david).reload.name
assert users(:david).authenticate("secret123456")
end
test "attempts to change the password are rate limited like signing in" do
Users::ProfilesController.cache_store.stubs(:increment).returns(11)
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_redirected_to user_profile_url
assert_equal "Too many attempts. Try again in a few minutes.", flash[:alert]
assert users(:david).reload.authenticate("secret123456")
put user_profile_url, params: { user: { password: "a new secret123456" } }
assert_equal "Enter your current password to change it.", flash[:alert]
put user_profile_url, params: { user: { name: "Dave" } }
assert_equal "Dave", users(:david).reload.name
end
test "any current password sent counts toward the rate limit, however blank it looks" do
Users::ProfilesController.cache_store.stubs(:increment).returns(11)
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: " " } }
assert_equal "Too many attempts. Try again in a few minutes.", flash[:alert]
end
test "a current password that isn't text is no current password" do
[ false, [], {}, 0 ].each do |challenge|
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: challenge } }, as: :json
assert_redirected_to user_profile_url
assert_equal "Enter your current password to change it.", flash[:alert]
assert users(:david).reload.authenticate("secret123456")
end
end
test "changing the password removes the other devices' push subscriptions, and this browser can register its own again" do
stub_web_push_dns_resolution
endpoint = push_subscriptions(:david_chrome).attributes.slice("endpoint", "p256dh_key", "auth_key")
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
assert_empty users(:david).push_subscriptions
post user_push_subscriptions_url, params: { push_subscription: endpoint }
assert_response :ok
assert_equal [ endpoint["endpoint"] ], users(:david).push_subscriptions.pluck(:endpoint)
end
test "updates are limited to the current user" do
put user_profile_url(users(:jason)), params: { user: { name: "John Doe" } }
assert_equal "Jason", users(:jason).reload.name
end
private
def assert_password_change_refused(alert, params)
other_device = open_session
other_device.post session_url, params: { email_address: users(:david).email_address, password: "secret123456" }
ActionCable.server.expects(:remote_connections).never
assert_no_changes -> { [ Session.count, Push::Subscription.count ] } do
put user_profile_url, params: params
end
assert_redirected_to user_profile_url
assert_equal alert, flash[:alert]
assert users(:david).reload.authenticate("secret123456")
assert_equal "David", users(:david).name
other_device.get user_profile_url
other_device.assert_response :success
end
end
@@ -74,98 +74,4 @@ class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to user_push_subscriptions_url
end
end
test "a signed-out session can't register a subscription" do
Session.delete_all
assert_no_difference -> { Push::Subscription.count } do
post user_push_subscriptions_url, params: { push_subscription: new_subscription_params }
end
assert_redirected_to new_session_url
end
test "a session signed out while its request is in flight doesn't register a subscription" do
Push::Subscription.any_instance.stubs(:valid?).with { Session.delete_all; true }.returns(true)
assert_no_difference -> { Push::Subscription.count } do
post user_push_subscriptions_url, params: { push_subscription: new_subscription_params }
end
assert_response :unauthorized
end
test "a session signed out while its request is in flight doesn't keep an existing subscription alive" do
subscription = push_subscriptions(:david_chrome)
Push::Subscription.any_instance.stubs(:valid?).with { Session.delete_all; true }.returns(true)
assert_no_changes -> { subscription.reload.updated_at } do
post user_push_subscriptions_url, params: { push_subscription: subscription.attributes.slice("endpoint", "p256dh_key", "auth_key") }
end
assert_response :unauthorized
end
test "pages name their session for the browser to post its subscription again in a new one" do
get user_profile_url
assert_equal user_push_subscriptions_path, css_select("[data-push-subscription-sync-url-value]").first["data-push-subscription-sync-url-value"]
get room_url(rooms(:watercooler))
first_marker = css_select("[data-push-subscription-sync-session-value]").first["data-push-subscription-sync-session-value"]
assert_match(/\A\h{64}\z/, first_marker)
assert_not_includes response.body, users(:david).sessions.order(:id).last.token
sign_in :david
get room_url(rooms(:watercooler))
assert_not_equal first_marker, css_select("[data-push-subscription-sync-session-value]").first["data-push-subscription-sync-session-value"]
end
test "two posts of the same new subscription at once register it once" do
Push::Subscription.any_instance.stubs(:valid?).with do
users(:david).push_subscriptions.build(new_subscription_params).save!(validate: false) unless Push::Subscription.exists?(endpoint: new_subscription_params["endpoint"])
true
end.returns(true)
assert_difference -> { Push::Subscription.where(endpoint: new_subscription_params["endpoint"]).count }, +1 do
post user_push_subscriptions_url, params: { push_subscription: new_subscription_params }
end
assert_response :ok
end
private
def new_subscription_params
{ "endpoint" => "https://fcm.googleapis.com/fcm/send/abc123", "p256dh_key" => "123", "auth_key" => "456" }
end
end
class Users::PushSubscriptionsControllerForeignWriteTest < ActionDispatch::IntegrationTest
self.use_transactional_tests = false
setup do
sign_in :david
stub_web_push_dns_resolution
end
test "a subscription another connection registers while this one validates isn't registered twice" do
params = { "endpoint" => "https://fcm.googleapis.com/fcm/send/foreign", "p256dh_key" => "123", "auth_key" => "456" }
Push::Subscription.any_instance.stubs(:valid?).with { foreign_insert(params); true }.returns(true)
post user_push_subscriptions_url, params: { push_subscription: params }
assert_response :ok
assert_equal 1, Push::Subscription.where(endpoint: params["endpoint"]).count
end
private
def foreign_insert(params)
unless @inserted
SQLite3::Database.new(ActiveRecord::Base.connection_db_config.database) do |database|
database.execute "INSERT INTO push_subscriptions (user_id, endpoint, p256dh_key, auth_key, created_at, updated_at) " \
"VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))", [ users(:david).id, *params.values ]
end
@inserted = true
end
end
end
@@ -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
+1 -1
View File
@@ -172,7 +172,7 @@ class ContentFiltersTest < ActionView::TestCase
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body).to_html
assert_dom_equal body, filtered
assert_equal body, filtered
assert_match %r{<table>.*<th><p>Name</p></th>.*<td><p>Jason</p></td>}m, message_presentation(message)
end
-53
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)
@@ -37,47 +25,6 @@ class MessagesHelperTest < ActionView::TestCase
assert_match /<strong>bold<\/strong>/, presentation
end
test "message_presentation links URLs and email addresses in text" do
message = Message.create! room: rooms(:pets), body: "<div>See https://example.com/a?b=1&amp;c=2, or write to me@example.com.</div>", client_message_id: "0015", creator: users(:jason)
links = Nokogiri::HTML5.fragment(view.message_presentation(message)).css("a")
assert_equal [ "https://example.com/a?b=1&c=2", "mailto:me@example.com" ], links.map { it["href"] }
assert_equal [ "_blank", "_blank" ], links.map { it["target"] }
assert_equal [ "https://example.com/a?b=1&c=2", "me@example.com" ], links.map(&:text)
end
test "message_presentation links an email address with an apostrophe whole" do
message = Message.create! room: rooms(:pets), body: "<div>Write to o'brien@example.com</div>", client_message_id: "0015", creator: users(:jason)
links = Nokogiri::HTML5.fragment(view.message_presentation(message)).css("a")
assert_equal [ [ "mailto:o%27brien@example.com", "o'brien@example.com" ] ], links.map { [ it["href"], it.text ] }
end
test "message_presentation leaves URLs and email addresses in attribute values as they are" do
[ "class", "title" ].product([ "https://example.com", "www.example.com", "me@example.com" ]).each do |attribute, linkable|
value = "a> #{linkable} <em>b</em> "
message = Message.create! room: rooms(:pets), body: %(<div><span #{attribute}="#{value}">c</span> https://example.org</div>), client_message_id: "0015", creator: users(:jason)
presentation = Nokogiri::HTML5.fragment(view.message_presentation(message))
assert_equal [ value ], presentation.css("span[#{attribute}]").map { it[attribute] }, "#{attribute} with #{linkable}"
assert_empty presentation.css("em"), "#{attribute} with #{linkable}"
assert_equal [ "https://example.org" ], presentation.css("a").map { it["href"] }, "#{attribute} with #{linkable}"
end
end
test "linking text doesn't eat a code block's leading blank line" do
linked = view.send(:auto_link_text, "<pre>\n\n\nfirst https://example.com</pre>".html_safe)
assert_equal "\nfirst https://example.com", Nokogiri::HTML5.fragment(linked).at_css("pre").text
end
test "message_presentation leaves an existing link's text alone" do
message = Message.create! room: rooms(:pets), body: '<div><a href="https://example.com/">https://example.org/</a></div>', client_message_id: "0015", creator: users(:jason)
links = Nokogiri::HTML5.fragment(view.message_presentation(message)).css("a")
assert_equal [ [ "https://example.com/", "https://example.org/" ] ], links.map { [ it["href"], it.text ] }
end
test "message_presentation shows an image's thumbnail made when it was posted" do
presentation = view.message_presentation(attachment_message("moon.jpg", "image/jpeg", processed: true))
@@ -1,197 +0,0 @@
require "test_helper"
class ContentSecurityPolicyTest < ActionDispatch::IntegrationTest
PAGE_PATH = "/"
setup { sign_in :david }
test "enforcing policy allows exactly the inline scripts the page carries" do
get root_url
follow_redirect! while response.redirect?
policy = response.headers["Content-Security-Policy"]
assert policy, "expected an enforcing Content-Security-Policy header"
assert_nil response.headers["Content-Security-Policy-Report-Only"]
assert_match "object-src 'none'", policy
assert_match "base-uri 'self'", policy
assert_no_match "unsafe-inline", policy
assert_no_match "nonce-", policy
assert_equal "www.example.com/service-worker.js", policy[/worker-src ([^;]+)/, 1]
script_src = policy[/script-src ([^;]+)/, 1].split
assert_not_includes script_src, "'self'"
assert_includes script_src, "www.example.com/assets/"
inline = Nokogiri::HTML5(response.body).css("script").reject { |s| s["src"] || s["type"] == "text/template" }
assert_operator inline.size, :>=, 2
inline.each do |script|
assert_includes script_src, "'sha256-#{Digest::SHA256.base64digest(script.text)}'", "inline #{script["type"]} script not allowed by hash"
end
end
test "scripts may load from a configured asset host, however it's written" do
{
"https://cdn.example.test" => %r{\Ahttps://cdn\.example\.test\z},
"cdn.example.test" => %r{\Ahttp://cdn\.example\.test\z},
"//cdn.example.test" => %r{\Ahttp://cdn\.example\.test\z},
"https://assets%d.example.test" => %r{\Ahttps://assets[0-3]\.example\.test\z},
->(source) { "https://one.example.test" } => %r{\Ahttps://one\.example\.test\z},
->(source, request) { "https://#{request.host}.example.test" } => %r{\Ahttps://www\.example\.com\.example\.test\z}
}.each do |asset_host, expected_origin|
with_asset_host(asset_host) do
get PAGE_PATH
follow_redirect! while response.redirect?
assert_response :success
script_src = response.headers["Content-Security-Policy"][/script-src ([^;]+)/, 1].split
page = Nokogiri::HTML5(response.body)
urls = JSON.parse(page.at_css("script[type=importmap]").text)["imports"].values +
page.css("link[rel=modulepreload]").map { it["href"] } + page.css("script[src]").map { it["src"] }
origins = urls.map { URI.join(request.base_url, it).origin }.uniq - [ request.base_url ]
assert_not_empty origins, asset_host.inspect
origins.each { assert_match expected_origin, it, asset_host.inspect }
assert_scripts_allowed_by_path script_src, urls.map { URI.join(request.base_url, it).to_s }, asset_host.inspect
assert_includes script_src, "'sha256-#{Digest::SHA256.base64digest(page.at_css("script[type=importmap]").text)}'", asset_host.inspect
end
end
end
test "an asset host whose path contains the asset directory is limited to the directory it serves" do
with_asset_host("https://cdn.example.test/assets") do
get PAGE_PATH
follow_redirect! while response.redirect?
script_src = response.headers["Content-Security-Policy"][/script-src ([^;]+)/, 1].split
assert_includes script_src, "https://cdn.example.test/assets/assets/"
assert_not allowed_by_path?(script_src, "https://cdn.example.test/assets/rails/active_storage/blobs/proxy/x/upload.js")
end
end
test "the sign-in page is limited to the asset directory too" do
cookies.delete(:session_token)
get new_session_url
assert_response :success
script_src = response.headers["Content-Security-Policy"][/script-src ([^;]+)/, 1].split
assert_not_includes script_src, "'self'"
assert_includes script_src, "www.example.com/assets/"
end
test "scripts are limited to the asset directory under a relative root" do
with_relative_url_root("/chat") do
get PAGE_PATH
follow_redirect! while response.redirect?
assert_response :success
script_src = response.headers["Content-Security-Policy"][/script-src ([^;]+)/, 1].split
page = Nokogiri::HTML5(response.body)
urls = JSON.parse(page.at_css("script[type=importmap]").text)["imports"].values + page.css("link[rel=modulepreload]").map { it["href"] }
assert urls.all? { it.start_with?("/chat/assets/") }, urls.inspect
assert_includes script_src, "www.example.com/chat/assets/"
assert_scripts_allowed_by_path script_src, urls.map { URI.join(request.base_url, it).to_s }
end
end
test "this origin's sources keep a non-default port and take the page's scheme" do
host! "www.example.com:8443"
get PAGE_PATH
follow_redirect! while response.redirect?
policy = response.headers["Content-Security-Policy"]
assert_includes policy[/script-src ([^;]+)/, 1].split, "www.example.com:8443/assets/"
assert_equal "www.example.com:8443/service-worker.js", policy[/worker-src ([^;]+)/, 1]
end
test "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?
script_src = response.headers["Content-Security-Policy"][/script-src ([^;]+)/, 1].split
blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new("alert(1)"), filename: "upload.js", content_type: "text/javascript")
disk_url = ActiveStorage::Current.set(url_options: { protocol: "http", host: "www.example.com" }) { blob.url }
[ rails_blob_url(blob), rails_blob_url(blob, disposition: :inline), rails_storage_proxy_url(blob), disk_url ].each do |url|
assert_not allowed_by_path?(script_src, url), "#{url} allowed by #{script_src.inspect}"
end
end
test "with the policy turned off, pages send none" do
without_policy do
get PAGE_PATH
follow_redirect! while response.redirect?
assert_response :success
assert_nil response.headers["Content-Security-Policy"]
end
end
test "the escape hatch boots without a policy" do
output = IO.popen({ "DISABLE_CONTENT_SECURITY_POLICY" => "true" },
[ "bin/rails", "runner", "print Rails.application.config.content_security_policy.inspect" ], chdir: Rails.root, err: File::NULL, &:read)
assert_equal "nil", output
end
test "uploaded files are guarded on their way out" do
assert_includes Rails.application.middleware.map(&:klass), UploadedScriptGuard
end
private
# What DISABLE_CONTENT_SECURITY_POLICY=true leaves at boot: no application policy
def without_policy
previous = Rails.application.env_config.delete("action_dispatch.content_security_policy")
yield
ensure
Rails.application.env_config["action_dispatch.content_security_policy"] = previous
end
# Every page script is allowed by a source with a path, never by a bare origin
def assert_scripts_allowed_by_path(script_src, urls, message = nil)
assert_empty script_src.grep(%r{\A([a-z]+://)?[^/']+/?\z}), message
urls.each { assert allowed_by_path?(script_src, it), "#{it} not allowed by #{script_src.inspect} #{message}" }
end
# CSP source matching on a first request: 'self' and a bare origin match any
# path, a path ending in / matches as a prefix, any other path exactly. A
# source without a scheme takes the page's.
def allowed_by_path?(script_src, url)
url = URI(url)
script_src.any? do |source|
unless source.start_with?("'") && source != "'self'"
source = URI(source == "'self'" ? request.base_url : (source.include?("://") ? source : "#{request.scheme}://#{source}"))
end
source.is_a?(URI) && source.origin == url.origin &&
(source.path.empty? || (source.path.end_with?("/") ? url.path.start_with?(source.path) : url.path == source.path))
end
end
def with_relative_url_root(root)
previous, ApplicationController.config.relative_url_root = ApplicationController.config.relative_url_root, root
Rails.application.importmap.send(:clear_cache)
yield
ensure
ApplicationController.config.relative_url_root = previous
Rails.application.importmap.send(:clear_cache)
end
def with_asset_host(asset_host)
previous, ApplicationController.asset_host = ApplicationController.asset_host, asset_host
Rails.application.importmap.send(:clear_cache)
yield
ensure
ApplicationController.asset_host = previous
Rails.application.importmap.send(:clear_cache)
end
end
-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
@@ -1,42 +0,0 @@
require "test_helper"
class UploadedScriptGuardTest < ActiveSupport::TestCase
# Every JavaScript MIME type essence in the MIME Sniffing Standard, plus
# parameters and casing a signed Disk URL might carry.
SCRIPT_TYPES = %w[
application/ecmascript application/javascript application/x-ecmascript application/x-javascript
text/ecmascript text/javascript text/javascript1.0 text/javascript1.1 text/javascript1.2 text/javascript1.3
text/javascript1.4 text/javascript1.5 text/jscript text/livescript text/x-ecmascript text/x-javascript
] + [ "Text/JavaScript; charset=utf-8" ]
test "uploads never leave with a script type, whatever the URL says" do
SCRIPT_TYPES.each do |type|
assert_equal ActiveStorage.binary_content_type, served_type(type, "#{ActiveStorage.routes_prefix}/disk/key/probe.js"), type
end
end
test "media and downloads keep their type" do
%w[ image/png video/mp4 audio/mpeg application/pdf text/plain ].each do |type|
assert_equal type, served_type(type, "#{ActiveStorage.routes_prefix}/disk/key/file"), type
end
end
test "multi-range responses keep their boundary" do
type = "multipart/byteranges; boundary=AaB03x"
assert_equal type, served_type(type, "#{ActiveStorage.routes_prefix}/blobs/proxy/key/movie.mp4")
end
test "direct upload responses keep their JSON type" do
assert_equal "application/json", served_type("application/json", "#{ActiveStorage.routes_prefix}/direct_uploads", method: "POST")
end
test "application responses outside uploads are untouched" do
assert_equal "text/javascript", served_type("text/javascript", "/assets/application-abc123.js")
end
private
def served_type(type, path, method: "GET")
app = ->(env) { [ 200, { "content-type" => type }, [ "" ] ] }
UploadedScriptGuard.new(app).call(Rack::MockRequest.env_for(path, method: method))[1]["content-type"]
end
end
-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
-18
View File
@@ -74,24 +74,6 @@ class Opengraph::FetchTest < ActiveSupport::TestCase
end
end
test "#fetch_document connects to the resolved IP even when a proxy is configured" do
url = URI.parse("http://www.example.com/")
saved = ENV.slice("http_proxy", "HTTP_PROXY", "no_proxy", "NO_PROXY")
%w[ no_proxy NO_PROXY ].each { |k| ENV.delete(k) }
%w[ http_proxy HTTP_PROXY ].each { |k| ENV[k] = "http://proxy.internal:3128" }
WebMock.disable_net_connect! allow: [ url.host ]
TCPSocket.expects(:open).with { |*args, **| args.first == "proxy.internal" }.never
TCPSocket.expects(:open).with { |*args, **| args.first == "1.2.3.4" && args[1] == 80 }.throws(:not_proxied)
assert_throws :not_proxied do
@fetch.fetch_document(url, ip: "1.2.3.4")
end
ensure
%w[ http_proxy HTTP_PROXY no_proxy NO_PROXY ].each { |k| ENV.delete(k) }
saved.each { |k, v| ENV[k] = v }
end
test "#fetch_document is empty following redirects that never finish" do
WebMock.stub_request(:get, "https://www.example.com/")
.to_return(status: 302, headers: { location: "https://www.example.com/" })
-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
-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
-40
View File
@@ -1,40 +0,0 @@
require "test_helper"
class User::TransferableTest < ActiveSupport::TestCase
test "a transfer id finds its user" do
assert_equal users(:david), User.find_by_transfer_id(users(:david).transfer_id)
end
test "a transfer id expires" do
transfer_id = users(:david).transfer_id
travel User::TRANSFER_LINK_EXPIRY_DURATION + 1.second
assert_nil User.find_by_transfer_id(transfer_id)
end
test "a transfer id made before a password change stops working" do
transfer_id = users(:david).transfer_id
users(:david).update!(password: "a new secret123456")
assert_nil User.find_by_transfer_id(transfer_id)
assert_equal users(:david), User.find_by_transfer_id(users(:david).transfer_id)
end
test "other changes keep a transfer id working" do
transfer_id = users(:david).transfer_id
users(:david).update!(name: "Dave", email_address: "dave@37signals.com")
assert_equal users(:david), User.find_by_transfer_id(transfer_id)
end
test "a transfer id finds only users in scope" do
transfer_id = users(:david).transfer_id
users(:david).deactivate
assert_nil User.active.find_by_transfer_id(transfer_id)
end
end
-72
View File
@@ -6,14 +6,6 @@ class UserTest < ActiveSupport::TestCase
assert users(:david).valid?
end
test "a password challenge is checked whenever the password would change" do
assert_not User.find(users(:david).id).update(password: "a new secret123456", password_challenge: "")
assert_not User.find(users(:david).id).update(password: "a new secret123456", password_challenge: "not my password")
assert User.find(users(:david).id).update(name: "Dave", password_challenge: "")
assert User.find(users(:david).id).update(password: "a new secret123456", password_challenge: "secret123456")
assert User.find(users(:david).id).update(password: "another secret123456")
end
test "creating users grants membership to the open rooms" do
assert_difference -> { Membership.count }, +Rooms::Open.count do
create_new_user
@@ -50,70 +42,6 @@ class UserTest < ActiveSupport::TestCase
end
end
test "changing the password signs out every session, the current one included" do
Current.session = users(:david).sessions.create!(user_agent: "This device", ip_address: "127.0.0.1")
assert_changes -> { users(:david).sessions.count }, to: 0 do
users(:david).update!(password: "a new secret123456")
end
end
test "changing the password closes the account's live connections" do
remote_connections = mock
remote_connections.expects(:disconnect).with(reconnect: true)
ActionCable.server.stubs(:remote_connections).returns(mock.tap { |m| m.expects(:where).with(current_user: users(:david)).returns(remote_connections) })
users(:david).update!(password: "a new secret123456")
end
test "changing the password closes live connections even when the user is saved again before commit" do
remote_connections = mock
remote_connections.expects(:disconnect).with(reconnect: true)
ActionCable.server.stubs(:remote_connections).returns(mock.tap { |m| m.expects(:where).with(current_user: users(:david)).returns(remote_connections) })
User.transaction do
users(:david).update!(password: "a new secret123456")
users(:david).update!(name: "Dave")
end
end
test "a password change that rolls back keeps every session and connection" do
ActionCable.server.expects(:remote_connections).never
assert_no_changes -> { users(:david).sessions.count } do
User.transaction do
users(:david).update!(password: "a new secret123456")
raise ActiveRecord::Rollback
end
end
end
test "changing the password removes every push subscription of the user" do
assert users(:david).push_subscriptions.any?
users(:david).update!(password: "a new secret123456")
assert_empty users(:david).push_subscriptions.reload
assert users(:jason).push_subscriptions.any?
end
test "a password change that rolls back keeps push subscriptions" do
assert_no_changes -> { users(:david).push_subscriptions.count } do
User.transaction do
users(:david).update!(password: "a new secret123456")
raise ActiveRecord::Rollback
end
end
end
test "changing other details keeps every session" do
ActionCable.server.expects(:remote_connections).never
assert_no_changes -> { [ users(:david).sessions.count, users(:david).push_subscriptions.count ] } do
users(:david).update!(name: "Dave", email_address: "dave@37signals.com")
end
end
private
def create_new_user
User.create!(name: "User", email_address: "user@example.com", password: "secret123456")
+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++) {
-21
View File
@@ -1,21 +0,0 @@
require "test_helper"
class ResetPasswordScriptTest < ActiveSupport::TestCase
test "resets a password without the current one and signs out every session" do
assert_output "Password has been reset\n" do
run_script "david@37signals.com", "a new secret123456"
end
assert users(:david).reload.authenticate("a new secret123456")
assert_empty users(:david).sessions
end
private
def run_script(*arguments)
original_arguments = ARGV.dup
ARGV.replace(arguments)
load Rails.root.join("script/admin/reset-password").to_s
ensure
ARGV.replace(original_arguments)
end
end
-14
View File
@@ -174,20 +174,6 @@ class ComposerTest < ApplicationSystemTestCase
assert_selector last_message_selector("cite"), text: "JZ"
end
test "replying quotes a mention's text as text" do
message = rooms(:designers).messages.create! creator: users(:kevin), client_message_id: "reply-mention",
body: %(<div><span class="mention">&lt;em&gt;Kevin&lt;/em&gt;</span> says hi</div>)
join_room rooms(:designers)
within_message message do
reveal_message_actions
find("[aria-label='Reply']").click
end
assert_composer_text "<em>Kevin</em> says hi"
assert_no_selector "#composer lexxy-editor em"
end
test "arrow up edits my last message when the composer is empty" do
composer_editor.click
press_in_composer :up
@@ -1,74 +0,0 @@
require "application_system_test_case"
class PushSubscriptionResyncTest < ApplicationSystemTestCase
ENDPOINT = "https://fcm.googleapis.com/fcm/send/system-test"
setup do
stub_web_push_dns_resolution
fake_a_browser_push_subscription
end
teardown do
page.driver.browser.execute_cdp "Page.removeScriptToEvaluateOnNewDocument", identifier: @fake_subscription_script
page.execute_script("localStorage.clear()") if page.current_url.start_with?("http")
end
test "a browser posts its subscription again once per session, so it survives a password change" do
sign_in "david@37signals.com"
assert_subscription_registered
first_sync = subscription.updated_at
join_room rooms(:hq)
assert_equal first_sync, subscription.reload.updated_at, "posted again in the same session"
first_id = subscription.id
change_password
assert_subscription_registered
assert_not_equal first_id, subscription.id, "not removed and posted again by the profile page"
assert_not page.evaluate_script("localStorage.getItem('unsubscribed-for-test')")
end
test "a subscription the server refuses stays in the browser" do
stub_dns_resolution("169.254.169.254")
sign_in "david@37signals.com"
Timeout.timeout(5) { sleep 0.1 until page.evaluate_script("localStorage.getItem('push-subscription-synced-session')") }
join_room rooms(:hq)
assert_not Push::Subscription.exists?(endpoint: ENDPOINT)
assert_not page.evaluate_script("localStorage.getItem('unsubscribed-for-test')")
end
private
# Headless Chrome has no push service, so stand in for the subscription the service worker would hold. The
# browser is shared with other system tests, so teardown removes this again.
def fake_a_browser_push_subscription
@fake_subscription_script = page.driver.browser.execute_cdp("Page.addScriptToEvaluateOnNewDocument", source: <<~JS)["identifier"]
(() => {
Object.defineProperty(Notification, "permission", { get: () => "granted" })
const subscription = {
toJSON: () => ({ endpoint: "#{ENDPOINT}", keys: { p256dh: "p256dh-key", auth: "auth-key" } }),
unsubscribe: async () => { localStorage.setItem("unsubscribed-for-test", "true") }
}
navigator.serviceWorker.getRegistration = async () => ({ pushManager: { getSubscription: async () => subscription } })
})()
JS
end
def change_password
visit user_profile_url
find("input[name='user[password_challenge]']").fill_in with: "secret123456"
find("input[name='user[password]']").fill_in with: "a new secret123456"
find("input[name='user[password]']").send_keys :enter
assert_selector ".flash", text: "✓"
end
def assert_subscription_registered
assert_selector "[data-controller~=push-subscription-sync]", visible: :all
Timeout.timeout(5) { sleep 0.1 until Push::Subscription.exists?(endpoint: ENDPOINT, user: users(:david)) }
end
def subscription
Push::Subscription.find_by!(endpoint: ENDPOINT)
end
end
@@ -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