mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-28 00:16:48 +09:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aa4141077 | ||
|
|
27e9257b2a | ||
|
|
dd247a623b | ||
|
|
3b509f55ca | ||
|
|
ee37809220 | ||
|
|
5c5c82b27a | ||
|
|
2d749057f9 | ||
|
|
d28ab55bf4 | ||
|
|
50f25cea80 | ||
|
|
d79225ecd1 |
No files matched your search
@@ -67,6 +67,17 @@ jobs:
|
|||||||
CANONICAL_IMAGE="${REGISTRY}/${IMAGE_REF,,}"
|
CANONICAL_IMAGE="${REGISTRY}/${IMAGE_REF,,}"
|
||||||
echo "canonical=${CANONICAL_IMAGE}" >> "$GITHUB_OUTPUT"
|
echo "canonical=${CANONICAL_IMAGE}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Compute app version
|
||||||
|
id: version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||||
|
echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "version=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Extract Docker metadata (tags, labels) with arch suffix
|
- name: Extract Docker metadata (tags, labels) with arch suffix
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||||
@@ -94,6 +105,8 @@ jobs:
|
|||||||
build-args: |
|
build-args: |
|
||||||
OCI_SOURCE=${{ env.SOURCE_URL }}
|
OCI_SOURCE=${{ env.SOURCE_URL }}
|
||||||
OCI_DESCRIPTION=${{ env.IMAGE_DESCRIPTION }}
|
OCI_DESCRIPTION=${{ env.IMAGE_DESCRIPTION }}
|
||||||
|
APP_VERSION=${{ steps.version.outputs.version }}
|
||||||
|
GIT_REVISION=${{ github.sha }}
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ ENV HTTP_WRITE_TIMEOUT=300
|
|||||||
COPY --from=build --chown=rails:rails /usr/local/bundle /usr/local/bundle
|
COPY --from=build --chown=rails:rails /usr/local/bundle /usr/local/bundle
|
||||||
COPY --from=build --chown=rails:rails /rails /rails
|
COPY --from=build --chown=rails:rails /rails /rails
|
||||||
|
|
||||||
|
# Install ONCE backup/restore hooks
|
||||||
|
COPY --chmod=755 hooks /hooks
|
||||||
|
|
||||||
# Set version and revision
|
# Set version and revision
|
||||||
ARG APP_VERSION
|
ARG APP_VERSION
|
||||||
ENV APP_VERSION=$APP_VERSION
|
ENV APP_VERSION=$APP_VERSION
|
||||||
|
|||||||
@@ -53,3 +53,7 @@ If you'd rather run the Docker image yourself, you can read more about that in t
|
|||||||
|
|
||||||
You are welcome - and encouraged - to modify Campfire to your liking.
|
You are welcome - and encouraged - to modify Campfire to your liking.
|
||||||
Please see our [development guide](docs/development.md) for how to get Campfire set up for local development.
|
Please see our [development guide](docs/development.md) for how to get Campfire set up for local development.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
See [SECURITY.md](SECURITY.md) for how to report a vulnerability and a description of our trust model.
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
Report vulnerabilities through our [HackerOne program](https://hackerone.com/basecamp) or at
|
||||||
|
https://github.com/basecamp/once-campfire/security. Note that Campfire is in scope for our security
|
||||||
|
program, but is not bounty eligible.
|
||||||
|
|
||||||
|
## Trust model
|
||||||
|
|
||||||
|
Campfire is self-hosted and single-tenant, so the administrator is the server operator,
|
||||||
|
with shell, network, and database access already. Anything requiring the administrator
|
||||||
|
role grants nothing they do not already have, and is not a vulnerability.
|
||||||
|
|
||||||
|
We do want reports of anything a **non-administrator** can reach, and of any credential or
|
||||||
|
network path held by the Campfire process but not by the operator's own shell.
|
||||||
|
|
||||||
|
## Intentional behavior
|
||||||
|
|
||||||
|
Bot webhook URLs are unrestricted: an administrator can point one at any address,
|
||||||
|
including internal ones, because operators legitimately wire bots to their own services.
|
||||||
|
Link unfurling is different because any member can trigger it by pasting a URL, so it
|
||||||
|
validates destinations through `RestrictedHTTP::PrivateNetworkGuard`. The difference is who
|
||||||
|
picks the destination.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Prepended onto Turbo::StreamsChannel. The subscriber names the channel it wants, so
|
||||||
|
# authorizing room messages only in RoomMessagesChannel would leave the stock channel as
|
||||||
|
# a way around it: same signed stream name, no membership check. Turn those names away
|
||||||
|
# here and RoomMessagesChannel becomes the only door.
|
||||||
|
module RoomStreamsAreAuthorized
|
||||||
|
def subscribed
|
||||||
|
if RoomMessagesChannel.guarded_stream?(verified_stream_name_from_params)
|
||||||
|
reject
|
||||||
|
else
|
||||||
|
super
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Authorizes the room message stream when the subscription is made, so that revoking a
|
||||||
|
# membership actually stops delivery.
|
||||||
|
#
|
||||||
|
# Turbo's stock channel verifies only the signature on the stream name. That name carries
|
||||||
|
# no expiry and no binding to a user, so one harvested while a member keeps working after
|
||||||
|
# the membership is gone. Reconnecting makes it worse rather than better: revoking a
|
||||||
|
# membership disconnects the user with reconnect: true, and the client then replays its
|
||||||
|
# subscriptions on the fresh socket.
|
||||||
|
#
|
||||||
|
# The room is derived from the verified stream name rather than taken as a parameter, so
|
||||||
|
# there's nothing for a subscriber to point somewhere else. The subscriber also doesn't
|
||||||
|
# get to choose the channel: Turbo::StreamsChannel turns these stream names away, so this
|
||||||
|
# is the only way onto them. See config/initializers/turbo_streams_authorization.rb.
|
||||||
|
class RoomMessagesChannel < ApplicationCable::Channel
|
||||||
|
extend Turbo::Streams::StreamName
|
||||||
|
include Turbo::Streams::StreamName::ClassMethods
|
||||||
|
|
||||||
|
STREAM_SUFFIX = "messages"
|
||||||
|
|
||||||
|
class << self
|
||||||
|
# True for the stream names this channel exists to guard, whoever is asking.
|
||||||
|
def guarded_stream?(stream_name)
|
||||||
|
stream_name.to_s.split(":", 2).second == STREAM_SUFFIX
|
||||||
|
end
|
||||||
|
|
||||||
|
def subscribable_room(user, stream_name)
|
||||||
|
gid_param, suffix = stream_name.to_s.split(":", 2)
|
||||||
|
|
||||||
|
if suffix == STREAM_SUFFIX && room = room_from(gid_param)
|
||||||
|
user.rooms.find_by(id: room.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def room_from(gid_param)
|
||||||
|
GlobalID::Locator.locate gid_param, only: Room
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def subscribed
|
||||||
|
if stream_name = authorized_stream_name
|
||||||
|
stream_from stream_name
|
||||||
|
else
|
||||||
|
reject
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def authorized_stream_name
|
||||||
|
stream_name = verified_stream_name_from_params
|
||||||
|
stream_name if stream_name.present? && self.class.subscribable_room(current_user, stream_name)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
class UnreadRoomsChannel < ApplicationCable::Channel
|
class UnreadRoomsChannel < ApplicationCable::Channel
|
||||||
|
# Scoped per user, like ReadRoomsChannel. A single global stream would tell every
|
||||||
|
# authenticated connection when any room on the account is active, including closed
|
||||||
|
# rooms and direct conversations they're not part of.
|
||||||
|
def self.stream_name_for(user_id)
|
||||||
|
"user_#{user_id}_unreads"
|
||||||
|
end
|
||||||
|
|
||||||
def subscribed
|
def subscribed
|
||||||
stream_from "unread_rooms"
|
stream_from self.class.stream_name_for(current_user.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -42,6 +42,12 @@ class Rooms::ClosedsController < RoomsController
|
|||||||
@room = @room.becomes!(Rooms::Closed)
|
@room = @room.becomes!(Rooms::Closed)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Open and closed rooms convert into each other, so both are in reach here. Direct
|
||||||
|
# rooms never are: converting one would let its creator revise who's in it.
|
||||||
|
def room_scope
|
||||||
|
Current.user.rooms.without_directs
|
||||||
|
end
|
||||||
|
|
||||||
def grantees
|
def grantees
|
||||||
User.where(id: grantee_ids)
|
User.where(id: grantee_ids)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -29,8 +29,13 @@ class Rooms::DirectsController < RoomsController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# All users in a direct room can administer it
|
# All users in a direct room can administer it. Only direct rooms, though: this
|
||||||
|
# relaxation is why room_scope below has to keep every other type out of reach.
|
||||||
def ensure_can_administer
|
def ensure_can_administer
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def room_scope
|
||||||
|
Current.user.rooms.directs
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -40,6 +40,12 @@ class Rooms::OpensController < RoomsController
|
|||||||
@room = @room.becomes!(Rooms::Open)
|
@room = @room.becomes!(Rooms::Open)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Open and closed rooms convert into each other, so both are in reach here. Direct
|
||||||
|
# rooms never are: promoting one would republish its history to the whole account.
|
||||||
|
def room_scope
|
||||||
|
Current.user.rooms.without_directs
|
||||||
|
end
|
||||||
|
|
||||||
def broadcast_create_room(room)
|
def broadcast_create_room(room)
|
||||||
broadcast_prepend_to :rooms, target: :shared_rooms, partial: "users/sidebars/rooms/shared", locals: { room: room }
|
broadcast_prepend_to :rooms, target: :shared_rooms, partial: "users/sidebars/rooms/shared", locals: { room: room }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -20,13 +20,19 @@ class RoomsController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
def set_room
|
def set_room
|
||||||
if room = Current.user.rooms.find_by(id: params[:room_id] || params[:id])
|
if room = room_scope.find_by(id: params[:room_id] || params[:id])
|
||||||
@room = room
|
@room = room
|
||||||
else
|
else
|
||||||
redirect_to root_url, alert: "Room not found or inaccessible"
|
redirect_to root_url, alert: "Room not found or inaccessible"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Subclasses narrow this to the room types they're allowed to act on, so that one
|
||||||
|
# room namespace can't be used to reach another's rooms.
|
||||||
|
def room_scope
|
||||||
|
Current.user.rooms
|
||||||
|
end
|
||||||
|
|
||||||
def ensure_can_administer
|
def ensure_can_administer
|
||||||
head :forbidden unless Current.user.can_administer?(@room)
|
head :forbidden unless Current.user.can_administer?(@room)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
module Message::Broadcasts
|
module Message::Broadcasts
|
||||||
def broadcast_create
|
def broadcast_create
|
||||||
broadcast_append_to room, :messages, target: [ room, :messages ]
|
broadcast_append_to room, :messages, target: [ room, :messages ]
|
||||||
ActionCable.server.broadcast("unread_rooms", { roomId: room.id })
|
broadcast_unread_room
|
||||||
end
|
end
|
||||||
|
|
||||||
def broadcast_remove
|
def broadcast_remove
|
||||||
broadcast_remove_to room, :messages
|
broadcast_remove_to room, :messages
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
# Fanned out to the room's members rather than published on one global stream, so
|
||||||
|
# that the timing of activity in a room only reaches people who are in it.
|
||||||
|
def broadcast_unread_room
|
||||||
|
room.memberships.pluck(:user_id).each do |user_id|
|
||||||
|
ActionCable.server.broadcast UnreadRoomsChannel.stream_name_for(user_id), { roomId: room.id }
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -22,6 +22,8 @@ class Room < ApplicationRecord
|
|||||||
|
|
||||||
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
||||||
|
|
||||||
|
validate :direct_rooms_keep_their_type, on: :update
|
||||||
|
|
||||||
scope :opens, -> { where(type: "Rooms::Open") }
|
scope :opens, -> { where(type: "Rooms::Open") }
|
||||||
scope :closeds, -> { where(type: "Rooms::Closed") }
|
scope :closeds, -> { where(type: "Rooms::Closed") }
|
||||||
scope :directs, -> { where(type: "Rooms::Direct") }
|
scope :directs, -> { where(type: "Rooms::Direct") }
|
||||||
@@ -65,6 +67,15 @@ class Room < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
# Open and closed rooms convert into each other freely. A direct room can't become
|
||||||
|
# either: its participants agreed to a private conversation, not to one whose
|
||||||
|
# audience someone else gets to widen afterwards.
|
||||||
|
def direct_rooms_keep_their_type
|
||||||
|
if type_changed? && type_was == "Rooms::Direct"
|
||||||
|
errors.add :type, "can't be changed for a direct room"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def unread_memberships(message)
|
def unread_memberships(message)
|
||||||
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
|
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class Webhook < ApplicationRecord
|
|||||||
Net::HTTP::Post.new(uri, "Content-Type" => "application/json").tap { |request| request.body = payload }
|
Net::HTTP::Post.new(uri, "Content-Type" => "application/json").tap { |request| request.body = payload }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# No PrivateNetworkGuard, unlike Opengraph::Fetch: only an administrator sets this URL,
|
||||||
|
# and operators legitimately point bots at their own internal services.
|
||||||
def http
|
def http
|
||||||
Net::HTTP.new(uri.host, uri.port).tap do |http|
|
Net::HTTP.new(uri.host, uri.port).tap do |http|
|
||||||
http.use_ssl = (uri.scheme == "https")
|
http.use_ssl = (uri.scheme == "https")
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<%= render partial: "messages/message", collection: @messages, cached: true %>
|
<%= render partial: "messages/message", collection: @messages, cached: true %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= turbo_stream_from @room, :messages %>
|
<%= turbo_stream_from @room, :messages, channel: "RoomMessagesChannel" %>
|
||||||
<%= button_to_jump_to_newest_message %>
|
<%= button_to_jump_to_newest_message %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
|||||||
+17
-10
@@ -15,6 +15,7 @@ include SSHKit::DSL
|
|||||||
ARCH = "linux/amd64,linux/arm64"
|
ARCH = "linux/amd64,linux/arm64"
|
||||||
BUILDER_NAME = "campfire-builder"
|
BUILDER_NAME = "campfire-builder"
|
||||||
GIT_SHA = `git rev-parse HEAD`.strip
|
GIT_SHA = `git rev-parse HEAD`.strip
|
||||||
|
MANUAL_NOTES = ARGV.delete("--notes")
|
||||||
VERSION = ARGV[0]
|
VERSION = ARGV[0]
|
||||||
|
|
||||||
DOCKER_IMAGE_NAME = "registry.once.com/campfire"
|
DOCKER_IMAGE_NAME = "registry.once.com/campfire"
|
||||||
@@ -28,7 +29,7 @@ EXPORT_REMOTE_USER = "app"
|
|||||||
EXPORT_DEPLOY_HOSTS = %w[ once-store-app-101 once-store-app-102 ]
|
EXPORT_DEPLOY_HOSTS = %w[ once-store-app-101 once-store-app-102 ]
|
||||||
EXPORT_DEPLOY_LOCATION = "/local/app/product_releases/"
|
EXPORT_DEPLOY_LOCATION = "/local/app/product_releases/"
|
||||||
|
|
||||||
abort "You must specify a version, e.g. `bin/release 1.0.0`" unless VERSION
|
abort "You must specify a version, e.g. `bin/release 1.0.0` (pass --notes to write release notes manually)" unless VERSION
|
||||||
|
|
||||||
current_branch = `git rev-parse --abbrev-ref HEAD`.strip
|
current_branch = `git rev-parse --abbrev-ref HEAD`.strip
|
||||||
if current_branch != "main"
|
if current_branch != "main"
|
||||||
@@ -57,19 +58,25 @@ on(:local) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
announcing "Creating GitHub release v#{VERSION}..." do
|
announcing "Creating GitHub release v#{VERSION}..." do
|
||||||
notes_file = "tmp/release_notes.md"
|
|
||||||
|
|
||||||
on(:local) do
|
on(:local) do
|
||||||
execute :mkdir, "-p", "tmp"
|
if MANUAL_NOTES
|
||||||
|
notes_file = "tmp/release_notes.md"
|
||||||
|
|
||||||
File.write(notes_file, "# What's Changed\n\n- \n")
|
execute :mkdir, "-p", "tmp"
|
||||||
system ENV["EDITOR"] || "vim", notes_file
|
|
||||||
|
|
||||||
unless system "gh", "release", "create", "v#{VERSION}", "--title", "v#{VERSION}", "--notes-file", notes_file
|
File.write(notes_file, "# What's Changed\n\n- \n")
|
||||||
abort "Failed to create release; check that the version doesn't already exist"
|
system ENV["EDITOR"] || "vim", notes_file
|
||||||
|
|
||||||
|
unless system "gh", "release", "create", "v#{VERSION}", "--title", "v#{VERSION}", "--notes-file", notes_file
|
||||||
|
abort "Failed to create release; check that the version doesn't already exist"
|
||||||
|
end
|
||||||
|
|
||||||
|
execute :rm, notes_file
|
||||||
|
else
|
||||||
|
unless system "gh", "release", "create", "v#{VERSION}", "--title", "v#{VERSION}", "--generate-notes"
|
||||||
|
abort "Failed to create release; check that the version doesn't already exist"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
execute :rm, notes_file
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Rails.application.config.to_prepare do
|
||||||
|
Turbo::StreamsChannel.prepend RoomStreamsAreAuthorized
|
||||||
|
end
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
Rails.application.config.app_version = ENV.fetch("APP_VERSION", "0")
|
Rails.application.config.app_version = ENV["APP_VERSION"].presence || ENV["GIT_REVISION"].presence || "0"
|
||||||
Rails.application.config.git_revision = ENV["GIT_REVISION"]
|
Rails.application.config.git_revision = ENV["GIT_REVISION"]
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
db_name="${RAILS_ENV:-production}.sqlite3"
|
||||||
|
snapshot="/rails/storage/backups/$db_name"
|
||||||
|
db="/rails/storage/db/$db_name"
|
||||||
|
|
||||||
|
if [ -f "$snapshot" ]; then
|
||||||
|
mkdir -p "$(dirname "$db")"
|
||||||
|
cp "$snapshot" "$db"
|
||||||
|
rm -f "$db-wal" "$db-shm"
|
||||||
|
fi
|
||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
exec /rails/script/admin/prepare-backup
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class RoomMessagesChannelTest < ActionCable::Channel::TestCase
|
||||||
|
tests RoomMessagesChannel
|
||||||
|
|
||||||
|
setup do
|
||||||
|
@room = rooms(:designers)
|
||||||
|
@signed_stream_name = Turbo::StreamsChannel.signed_stream_name [ @room, :messages ]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a member may subscribe to a room's message stream" do
|
||||||
|
stub_connection(current_user: users(:kevin))
|
||||||
|
|
||||||
|
subscribe signed_stream_name: @signed_stream_name
|
||||||
|
|
||||||
|
assert subscription.confirmed?
|
||||||
|
assert_has_stream Turbo.signed_stream_verifier.verified(@signed_stream_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a user who was never a member may not subscribe" do
|
||||||
|
stub_connection(current_user: users(:bender))
|
||||||
|
|
||||||
|
subscribe signed_stream_name: @signed_stream_name
|
||||||
|
|
||||||
|
assert subscription.rejected?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a revoked member may not re-subscribe with a stream name harvested while a member" do
|
||||||
|
stub_connection(current_user: users(:kevin))
|
||||||
|
|
||||||
|
subscribe signed_stream_name: @signed_stream_name
|
||||||
|
assert subscription.confirmed?, "kevin must start out able to subscribe"
|
||||||
|
|
||||||
|
@room.memberships.revoke_from users(:kevin)
|
||||||
|
|
||||||
|
subscribe signed_stream_name: @signed_stream_name
|
||||||
|
|
||||||
|
assert subscription.rejected?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "an unsigned stream name is rejected" do
|
||||||
|
stub_connection(current_user: users(:kevin))
|
||||||
|
|
||||||
|
subscribe signed_stream_name: Turbo.signed_stream_verifier.verified(@signed_stream_name)
|
||||||
|
|
||||||
|
assert subscription.rejected?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a missing stream name is rejected" do
|
||||||
|
stub_connection(current_user: users(:kevin))
|
||||||
|
|
||||||
|
subscribe
|
||||||
|
|
||||||
|
assert subscription.rejected?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a validly signed stream name for another room the user isn't in is rejected" do
|
||||||
|
stub_connection(current_user: users(:bender))
|
||||||
|
|
||||||
|
subscribe signed_stream_name: Turbo::StreamsChannel.signed_stream_name([ rooms(:hq), :messages ])
|
||||||
|
|
||||||
|
assert subscription.rejected?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class RoomMessagesViaStockTurboChannelTest < ActionCable::Channel::TestCase
|
||||||
|
tests Turbo::StreamsChannel
|
||||||
|
|
||||||
|
# The subscriber picks the channel, so the stock channel has to turn these names away
|
||||||
|
# too. Otherwise a revoked member just names Turbo::StreamsChannel instead.
|
||||||
|
test "the stock turbo channel refuses to serve a room message stream" do
|
||||||
|
stub_connection(current_user: users(:kevin))
|
||||||
|
|
||||||
|
subscribe signed_stream_name: Turbo::StreamsChannel.signed_stream_name([ rooms(:designers), :messages ])
|
||||||
|
|
||||||
|
assert subscription.rejected?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the stock turbo channel still serves the room list stream" do
|
||||||
|
stub_connection(current_user: users(:kevin))
|
||||||
|
|
||||||
|
subscribe signed_stream_name: Turbo::StreamsChannel.signed_stream_name([ :rooms ])
|
||||||
|
|
||||||
|
assert subscription.confirmed?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class UnreadRoomsChannelTest < ActionCable::Channel::TestCase
|
||||||
|
test "streams only the subscriber's own unread stream" do
|
||||||
|
stub_connection(current_user: users(:jz))
|
||||||
|
|
||||||
|
subscribe
|
||||||
|
|
||||||
|
assert subscription.confirmed?
|
||||||
|
assert_has_stream "user_#{users(:jz).id}_unreads"
|
||||||
|
assert_not_includes subscription.streams, "unread_rooms"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "an outsider is not told about activity in a room they can't see" do
|
||||||
|
direct = rooms(:bender_and_kevin)
|
||||||
|
assert_not direct.users.include?(users(:jz)), "jz must be an outsider for this test to mean anything"
|
||||||
|
|
||||||
|
broadcasts = capture_unread_broadcasts_for(users(:jz)) do
|
||||||
|
direct.messages.create!(body: "Private", creator: users(:kevin), client_message_id: "outsider").broadcast_create
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_empty broadcasts
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a member is told about activity in their own room" do
|
||||||
|
direct = rooms(:bender_and_kevin)
|
||||||
|
|
||||||
|
broadcasts = capture_unread_broadcasts_for(users(:kevin)) do
|
||||||
|
direct.messages.create!(body: "Private", creator: users(:bender), client_message_id: "member").broadcast_create
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_equal [ direct.id ], broadcasts.collect { |broadcast| broadcast["roomId"] }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def capture_unread_broadcasts_for(user)
|
||||||
|
stub_connection(current_user: user)
|
||||||
|
subscribe
|
||||||
|
|
||||||
|
stream = subscription.streams.sole
|
||||||
|
before = ActionCable.server.pubsub.broadcasts(stream).size
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
ActionCable.server.pubsub.broadcasts(stream).drop(before).collect { |broadcast| JSON.parse(broadcast) }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -57,9 +57,22 @@ class MessagesControllerTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "creating a message broadcasts unread room" do
|
test "creating a message broadcasts unread room to each member" do
|
||||||
assert_broadcasts "unread_rooms", 1 do
|
@room.users.each do |member|
|
||||||
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } }
|
assert_broadcasts UnreadRoomsChannel.stream_name_for(member.id), 1 do
|
||||||
|
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one #{member.id}", client_message_id: member.id } }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "creating a message doesn't broadcast unread room to non-members" do
|
||||||
|
outsiders = User.where.not(id: @room.users.map(&:id))
|
||||||
|
assert outsiders.any?, "need someone outside the room for this test to mean anything"
|
||||||
|
|
||||||
|
outsiders.each do |outsider|
|
||||||
|
assert_no_broadcasts UnreadRoomsChannel.stream_name_for(outsider.id) do
|
||||||
|
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,18 @@ class Rooms::ClosedsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert rooms(:designers).reload.name, "Designers"
|
assert rooms(:designers).reload.name, "Designers"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "a direct room can't be converted to closed and have its participants revised" do
|
||||||
|
sign_in :kevin
|
||||||
|
direct = rooms(:bender_and_kevin)
|
||||||
|
|
||||||
|
put rooms_closed_url(direct), params: {
|
||||||
|
room: { name: "Watercooler" }, user_ids: [ users(:kevin).id, users(:jz).id ]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_equal "Rooms::Direct", Room.find(direct.id).type
|
||||||
|
assert_equal [ users(:bender).id, users(:kevin).id ].sort, Room.find(direct.id).user_ids.sort
|
||||||
|
end
|
||||||
|
|
||||||
test "remove yourself" do
|
test "remove yourself" do
|
||||||
assert_difference -> { users(:david).rooms.count }, -1 do
|
assert_difference -> { users(:david).rooms.count }, -1 do
|
||||||
put rooms_closed_url(rooms(:designers), params: { room: { name: "Designers" }, user_ids: [ users(:jason).id, users(:jz).id ] })
|
put rooms_closed_url(rooms(:designers), params: { room: { name: "Designers" }, user_ids: [ users(:jason).id, users(:jz).id ] })
|
||||||
|
|||||||
@@ -29,4 +29,34 @@ class Rooms::DirectsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_redirected_to root_url
|
assert_redirected_to root_url
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "destroy can't reach a closed room the member didn't create" do
|
||||||
|
sign_in :kevin
|
||||||
|
|
||||||
|
assert_no_difference -> { Room.count } do
|
||||||
|
delete rooms_direct_url(rooms(:designers))
|
||||||
|
end
|
||||||
|
|
||||||
|
assert rooms(:designers).reload.persisted?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "destroy can't reach an open room the member didn't create" do
|
||||||
|
sign_in :kevin
|
||||||
|
|
||||||
|
assert_no_difference -> { Room.count } do
|
||||||
|
delete rooms_direct_url(rooms(:hq))
|
||||||
|
end
|
||||||
|
|
||||||
|
assert rooms(:hq).reload.persisted?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "destroy can't reach a room the member isn't in at all" do
|
||||||
|
sign_in :jz
|
||||||
|
|
||||||
|
assert_no_difference -> { Room.count } do
|
||||||
|
delete rooms_direct_url(rooms(:david_and_kevin))
|
||||||
|
end
|
||||||
|
|
||||||
|
assert rooms(:david_and_kevin).reload.persisted?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -57,4 +57,23 @@ class Rooms::OpensControllerTest < ActionDispatch::IntegrationTest
|
|||||||
put rooms_open_url(rooms(:designers)), params: { room: { name: "Doesn't matter" } }
|
put rooms_open_url(rooms(:designers)), params: { room: { name: "Doesn't matter" } }
|
||||||
assert_equal rooms(:designers).memberships.count, User.count
|
assert_equal rooms(:designers).memberships.count, User.count
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "a direct room can't be promoted to open by its creator" do
|
||||||
|
sign_in :kevin
|
||||||
|
direct = rooms(:bender_and_kevin)
|
||||||
|
|
||||||
|
put rooms_open_url(direct), params: { room: { name: "Watercooler" } }
|
||||||
|
|
||||||
|
assert_equal "Rooms::Direct", Room.find(direct.id).type
|
||||||
|
assert_equal [ users(:bender).id, users(:kevin).id ].sort, Room.find(direct.id).user_ids.sort
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a direct room can't be promoted to open by an administrator either" do
|
||||||
|
direct = rooms(:david_and_kevin)
|
||||||
|
|
||||||
|
put rooms_open_url(direct), params: { room: { name: "Watercooler" } }
|
||||||
|
|
||||||
|
assert_equal "Rooms::Direct", Room.find(direct.id).type
|
||||||
|
assert_equal [ users(:david).id, users(:kevin).id ].sort, Room.find(direct.id).user_ids.sort
|
||||||
|
end
|
||||||
end
|
end
|
||||||
Reference in new issue
Block a user