diff options
39 files changed, 1090 insertions, 787 deletions
diff --git a/actioncable/CHANGELOG.md b/actioncable/CHANGELOG.md index 162de0df0b..f43a955a76 100644 --- a/actioncable/CHANGELOG.md +++ b/actioncable/CHANGELOG.md @@ -1,3 +1,7 @@ +* Merge [`action-cable-testing`](https://github.com/palkan/action-cable-testing) to Rails. + + *Vladimir Dementyev* + * The JavaScript WebSocket client will no longer try to reconnect when you call `reject_unauthorized_connection` on the connection. diff --git a/actioncable/README.md b/actioncable/README.md index 84cf817d74..60c879e1f4 100644 --- a/actioncable/README.md +++ b/actioncable/README.md @@ -7,550 +7,7 @@ and scalable. It's a full-stack offering that provides both a client-side JavaScript framework and a server-side Ruby framework. You have access to your full domain model written with Active Record or your ORM of choice. -## Terminology - -A single Action Cable server can handle multiple connection instances. It has one -connection instance per WebSocket connection. A single user may have multiple -WebSockets open to your application if they use multiple browser tabs or devices. -The client of a WebSocket connection is called the consumer. - -Each consumer can in turn subscribe to multiple cable channels. Each channel encapsulates -a logical unit of work, similar to what a controller does in a regular MVC setup. For example, -you could have a `ChatChannel` and an `AppearancesChannel`, and a consumer could be subscribed to either -or to both of these channels. At the very least, a consumer should be subscribed to one channel. - -When the consumer is subscribed to a channel, they act as a subscriber. The connection between -the subscriber and the channel is, surprise-surprise, called a subscription. A consumer -can act as a subscriber to a given channel any number of times. For example, a consumer -could subscribe to multiple chat rooms at the same time. (And remember that a physical user may -have multiple consumers, one per tab/device open to your connection). - -Each channel can then again be streaming zero or more broadcastings. A broadcasting is a -pubsub link where anything transmitted by the broadcaster is sent directly to the channel -subscribers who are streaming that named broadcasting. - -As you can see, this is a fairly deep architectural stack. There's a lot of new terminology -to identify the new pieces, and on top of that, you're dealing with both client and server side -reflections of each unit. - -## Examples - -### A full-stack example - -The first thing you must do is define your `ApplicationCable::Connection` class in Ruby. This -is the place where you authorize the incoming connection, and proceed to establish it, -if all is well. Here's the simplest example starting with the server-side connection class: - -```ruby -# app/channels/application_cable/connection.rb -module ApplicationCable - class Connection < ActionCable::Connection::Base - identified_by :current_user - - def connect - self.current_user = find_verified_user - end - - private - def find_verified_user - if verified_user = User.find_by(id: cookies.encrypted[:user_id]) - verified_user - else - reject_unauthorized_connection - end - end - end -end -``` -Here `identified_by` is a connection identifier that can be used to find the specific connection again or later. -Note that anything marked as an identifier will automatically create a delegate by the same name on any channel instances created off the connection. - -This relies on the fact that you will already have handled authentication of the user, and -that a successful authentication sets a signed cookie with the `user_id`. This cookie is then -automatically sent to the connection instance when a new connection is attempted, and you -use that to set the `current_user`. By identifying the connection by this same current_user, -you're also ensuring that you can later retrieve all open connections by a given user (and -potentially disconnect them all if the user is deleted or deauthorized). - -Next, you should define your `ApplicationCable::Channel` class in Ruby. This is the place where you put -shared logic between your channels. - -```ruby -# app/channels/application_cable/channel.rb -module ApplicationCable - class Channel < ActionCable::Channel::Base - end -end -``` - -The client-side needs to setup a consumer instance of this connection. That's done like so: - -```js -// app/assets/javascripts/cable.js -//= require action_cable -//= require_self -//= require_tree ./channels - -(function() { - this.App || (this.App = {}); - - App.cable = ActionCable.createConsumer("ws://cable.example.com"); -}).call(this); -``` - -The `ws://cable.example.com` address must point to your Action Cable server(s), and it -must share a cookie namespace with the rest of the application (which may live under http://example.com). -This ensures that the signed cookie will be correctly sent. - -That's all you need to establish the connection! But of course, this isn't very useful in -itself. This just gives you the plumbing. To make stuff happen, you need content. That content -is defined by declaring channels on the server and allowing the consumer to subscribe to them. - - -### Channel example 1: User appearances - -Here's a simple example of a channel that tracks whether a user is online or not, and also what page they are currently on. -(This is useful for creating presence features like showing a green dot next to a user's name if they're online). - -First you declare the server-side channel: - -```ruby -# app/channels/appearance_channel.rb -class AppearanceChannel < ApplicationCable::Channel - def subscribed - current_user.appear - end - - def unsubscribed - current_user.disappear - end - - def appear(data) - current_user.appear on: data['appearing_on'] - end - - def away - current_user.away - end -end -``` - -The `#subscribed` callback is invoked when, as we'll show below, a client-side subscription is initiated. In this case, -we take that opportunity to say "the current user has indeed appeared". That appear/disappear API could be backed by -Redis or a database or whatever else. Here's what the client-side of that looks like: - -```coffeescript -# app/assets/javascripts/cable/subscriptions/appearance.coffee -App.cable.subscriptions.create "AppearanceChannel", - # Called when the subscription is ready for use on the server - connected: -> - @install() - @appear() - - # Called when the WebSocket connection is closed - disconnected: -> - @uninstall() - - # Called when the subscription is rejected by the server - rejected: -> - @uninstall() - - appear: -> - # Calls `AppearanceChannel#appear(data)` on the server - @perform("appear", appearing_on: $("main").data("appearing-on")) - - away: -> - # Calls `AppearanceChannel#away` on the server - @perform("away") - - - buttonSelector = "[data-behavior~=appear_away]" - - install: -> - $(document).on "turbolinks:load.appearance", => - @appear() - - $(document).on "click.appearance", buttonSelector, => - @away() - false - - $(buttonSelector).show() - - uninstall: -> - $(document).off(".appearance") - $(buttonSelector).hide() -``` - -Simply calling `App.cable.subscriptions.create` will setup the subscription, which will call `AppearanceChannel#subscribed`, -which in turn is linked to the original `App.cable` -> `ApplicationCable::Connection` instances. - -Next, we link the client-side `appear` method to `AppearanceChannel#appear(data)`. This is possible because the server-side -channel instance will automatically expose the public methods declared on the class (minus the callbacks), so that these -can be reached as remote procedure calls via a subscription's `perform` method. - -### Channel example 2: Receiving new web notifications - -The appearance example was all about exposing server functionality to client-side invocation over the WebSocket connection. -But the great thing about WebSockets is that it's a two-way street. So now let's show an example where the server invokes -an action on the client. - -This is a web notification channel that allows you to trigger client-side web notifications when you broadcast to the right -streams: - -```ruby -# app/channels/web_notifications_channel.rb -class WebNotificationsChannel < ApplicationCable::Channel - def subscribed - stream_from "web_notifications_#{current_user.id}" - end -end -``` - -```coffeescript -# Client-side, which assumes you've already requested the right to send web notifications -App.cable.subscriptions.create "WebNotificationsChannel", - received: (data) -> - new Notification data["title"], body: data["body"] -``` - -```ruby -# Somewhere in your app this is called, perhaps from a NewCommentJob -ActionCable.server.broadcast \ - "web_notifications_#{current_user.id}", { title: 'New things!', body: 'All the news that is fit to print' } -``` - -The `ActionCable.server.broadcast` call places a message in the Action Cable pubsub queue under a separate broadcasting name for each user. For a user with an ID of 1, the broadcasting name would be `web_notifications_1`. -The channel has been instructed to stream everything that arrives at `web_notifications_1` directly to the client by invoking the -`#received(data)` callback. The data is the hash sent as the second parameter to the server-side broadcast call, JSON encoded for the trip -across the wire, and unpacked for the data argument arriving to `#received`. - - -### Passing Parameters to Channel - -You can pass parameters from the client side to the server side when creating a subscription. For example: - -```ruby -# app/channels/chat_channel.rb -class ChatChannel < ApplicationCable::Channel - def subscribed - stream_from "chat_#{params[:room]}" - end -end -``` - -If you pass an object as the first argument to `subscriptions.create`, that object will become the params hash in your cable channel. The keyword `channel` is required. - -```coffeescript -# Client-side, which assumes you've already requested the right to send web notifications -App.cable.subscriptions.create { channel: "ChatChannel", room: "Best Room" }, - received: (data) -> - @appendLine(data) - - appendLine: (data) -> - html = @createLine(data) - $("[data-chat-room='Best Room']").append(html) - - createLine: (data) -> - """ - <article class="chat-line"> - <span class="speaker">#{data["sent_by"]}</span> - <span class="body">#{data["body"]}</span> - </article> - """ -``` - -```ruby -# Somewhere in your app this is called, perhaps from a NewCommentJob -ActionCable.server.broadcast \ - "chat_#{room}", { sent_by: 'Paul', body: 'This is a cool chat app.' } -``` - - -### Rebroadcasting message - -A common use case is to rebroadcast a message sent by one client to any other connected clients. - -```ruby -# app/channels/chat_channel.rb -class ChatChannel < ApplicationCable::Channel - def subscribed - stream_from "chat_#{params[:room]}" - end - - def receive(data) - ActionCable.server.broadcast "chat_#{params[:room]}", data - end -end -``` - -```coffeescript -# Client-side, which assumes you've already requested the right to send web notifications -App.chatChannel = App.cable.subscriptions.create { channel: "ChatChannel", room: "Best Room" }, - received: (data) -> - # data => { sent_by: "Paul", body: "This is a cool chat app." } - -App.chatChannel.send({ sent_by: "Paul", body: "This is a cool chat app." }) -``` - -The rebroadcast will be received by all connected clients, _including_ the client that sent the message. Note that params are the same as they were when you subscribed to the channel. - - -### More complete examples - -See the [rails/actioncable-examples](https://github.com/rails/actioncable-examples) repository for a full example of how to setup Action Cable in a Rails app, and how to add channels. - -## Configuration - -Action Cable has three required configurations: a subscription adapter, allowed request origins, and the cable server URL (which can optionally be set on the client side). - -### Redis - -By default, `ActionCable::Server::Base` will look for a configuration file in `Rails.root.join('config/cable.yml')`. -This file must specify an adapter and a URL for each Rails environment. It may use the following format: - -```yaml -production: &production - adapter: redis - url: redis://10.10.3.153:6381 -development: &development - adapter: redis - url: redis://localhost:6379 -test: *development -``` - -You can also change the location of the Action Cable config file in a Rails initializer with something like: - -```ruby -Rails.application.paths.add "config/cable", with: "somewhere/else/cable.yml" -``` - -### Allowed Request Origins - -Action Cable will only accept requests from specific origins. - -By default, only an origin matching the cable server itself will be permitted. -Additional origins can be specified using strings or regular expressions, provided in an array. - -```ruby -Rails.application.config.action_cable.allowed_request_origins = ['http://rubyonrails.com', /http:\/\/ruby.*/] -``` - -When running in the development environment, this defaults to "http://localhost:3000". - -To disable protection and allow requests from any origin: - -```ruby -Rails.application.config.action_cable.disable_request_forgery_protection = true -``` - -To disable automatic access for same-origin requests, and strictly allow -only the configured origins: - -```ruby -Rails.application.config.action_cable.allow_same_origin_as_host = false -``` - -### Consumer Configuration - -Once you have decided how to run your cable server (see below), you must provide the server URL (or path) to your client-side setup. -There are two ways you can do this. - -The first is to simply pass it in when creating your consumer. For a standalone server, -this would be something like: `App.cable = ActionCable.createConsumer("ws://example.com:28080")`, and for an in-app server, -something like: `App.cable = ActionCable.createConsumer("/cable")`. - -The second option is to pass the server URL through the `action_cable_meta_tag` in your layout. -This uses a URL or path typically set via `config.action_cable.url` in the environment configuration files, or defaults to "/cable". - -This method is especially useful if your WebSocket URL might change between environments. If you host your production server via https, you will need to use the wss scheme -for your Action Cable server, but development might remain http and use the ws scheme. You might use localhost in development and your -domain in production. - -In any case, to vary the WebSocket URL between environments, add the following configuration to each environment: - -```ruby -config.action_cable.url = "ws://example.com:28080" -``` - -Then add the following line to your layout before your JavaScript tag: - -```erb -<%= action_cable_meta_tag %> -``` - -And finally, create your consumer like so: - -```coffeescript -App.cable = ActionCable.createConsumer() -``` - -### Other Configurations - -The other common option to configure is the log tags applied to the per-connection logger. Here's an example that uses the user account id if available, else "no-account" while tagging: - -```ruby -config.action_cable.log_tags = [ - -> request { request.env['user_account_id'] || "no-account" }, - :action_cable, - -> request { request.uuid } -] -``` - -For a full list of all configuration options, see the `ActionCable::Server::Configuration` class. - -Also note that your server must provide at least the same number of database connections as you have workers. The default worker pool is set to 4, so that means you have to make at least that available. You can change that in `config/database.yml` through the `pool` attribute. - - -## Running the cable server - -### Standalone -The cable server(s) is separated from your normal application server. It's still a Rack application, but it is its own Rack -application. The recommended basic setup is as follows: - -```ruby -# cable/config.ru -require_relative '../config/environment' -Rails.application.eager_load! - -run ActionCable.server -``` - -Then you start the server using a binstub in bin/cable ala: -```sh -#!/bin/bash -bundle exec puma -p 28080 cable/config.ru -``` - -The above will start a cable server on port 28080. - -### In app - -If you are using a server that supports the [Rack socket hijacking API](https://www.rubydoc.info/github/rack/rack/file/SPEC#label-Hijacking), Action Cable can run alongside your Rails application. For example, to listen for WebSocket requests on `/websocket`, specify that path to `config.action_cable.mount_path`: - -```ruby -# config/application.rb -class Application < Rails::Application - config.action_cable.mount_path = '/websocket' -end -``` - -For every instance of your server you create and for every worker your server spawns, you will also have a new instance of Action Cable, but the use of Redis keeps messages synced across connections. - -### Notes - -Beware that currently, the cable server will _not_ auto-reload any changes in the framework. As we've discussed, long-running cable connections mean long-running objects. We don't yet have a way of reloading the classes of those objects in a safe manner. So when you change your channels, or the model your channels use, you must restart the cable server. - -We'll get all this abstracted properly when the framework is integrated into Rails. - -The WebSocket server doesn't have access to the session, but it has access to the cookies. This can be used when you need to handle authentication. You can see one way of doing that with Devise in this [article](https://greg.molnar.io/blog/actioncable-devise-authentication/). - -## Dependencies - -Action Cable provides a subscription adapter interface to process its pubsub internals. By default, asynchronous, inline, PostgreSQL, and Redis adapters are included. The default adapter in new Rails applications is the asynchronous (`async`) adapter. To create your own adapter, you can look at `ActionCable::SubscriptionAdapter::Base` for all methods that must be implemented, and any of the adapters included within Action Cable as example implementations. - -The Ruby side of things is built on top of [websocket-driver](https://github.com/faye/websocket-driver-ruby), [nio4r](https://github.com/celluloid/nio4r), and [concurrent-ruby](https://github.com/ruby-concurrency/concurrent-ruby). - - -## Deployment - -Action Cable is powered by a combination of WebSockets and threads. All of the -connection management is handled internally by utilizing Ruby's native thread -support, which means you can use all your regular Rails models with no problems -as long as you haven't committed any thread-safety sins. - -The Action Cable server does _not_ need to be a multi-threaded application server. -This is because Action Cable uses the [Rack socket hijacking API](https://www.rubydoc.info/github/rack/rack/file/SPEC#label-Hijacking) -to take over control of connections from the application server. Action Cable -then manages connections internally, in a multithreaded manner, regardless of -whether the application server is multi-threaded or not. So Action Cable works -with all the popular application servers -- Unicorn, Puma and Passenger. - -Action Cable does not work with WEBrick, because WEBrick does not support the -Rack socket hijacking API. - -## Frontend assets - -Action Cable's frontend assets are distributed through two channels: the -official gem and npm package, both titled `actioncable`. - -### Gem usage - -Through the `actioncable` gem, Action Cable's frontend assets are -available through the Rails Asset Pipeline. Create a `cable.js` or -`cable.coffee` file (this is automatically done for you with Rails -generators), and then simply require the assets: - -In JavaScript... - -```javascript -//= require action_cable -``` - -... and in CoffeeScript: - -```coffeescript -#= require action_cable -``` - -### npm usage - -In addition to being available through the `actioncable` gem, Action Cable's -frontend JS assets are also bundled in an officially supported npm module, -intended for usage in standalone frontend applications that communicate with a -Rails application. A common use case for this could be if you have a decoupled -frontend application written in React, Ember.js, etc. and want to add real-time -WebSocket functionality. - -### Installation - -``` -npm install @rails/actioncable --save -``` - -### Usage - -The `ActionCable` constant is available as a `require`-able module, so -you only have to require the package to gain access to the API that is -provided. - -In JavaScript... - -```javascript -ActionCable = require('@rails/actioncable') - -var cable = ActionCable.createConsumer('wss://RAILS-API-PATH.com/cable') - -cable.subscriptions.create('AppearanceChannel', { - // normal channel code goes here... -}); -``` - -and in CoffeeScript... - -```coffeescript -ActionCable = require('@rails/actioncable') - -cable = ActionCable.createConsumer('wss://RAILS-API-PATH.com/cable') - -cable.subscriptions.create 'AppearanceChannel', - # normal channel code goes here... -``` - -## Download and Installation - -The latest version of Action Cable can be installed with [RubyGems](#gem-usage), -or with [npm](#npm-usage). - -Source code can be downloaded as part of the Rails project on GitHub - -* https://github.com/rails/rails/tree/master/actioncable - -## License - -Action Cable is released under the MIT license: - -* https://opensource.org/licenses/MIT - +You can read more about Action Cable in the [Action Cable Overview](https://edgeguides.rubyonrails.org/action_cable_overview.html) guide. ## Support diff --git a/actioncable/app/assets/javascripts/action_cable.js b/actioncable/app/assets/javascripts/action_cable.js index 65e32d6c3f..a68c76f299 100644 --- a/actioncable/app/assets/javascripts/action_cable.js +++ b/actioncable/app/assets/javascripts/action_cable.js @@ -192,7 +192,7 @@ this.monitor.stop(); } if (this.isActive()) { - return this.webSocket ? this.webSocket.close() : undefined; + return this.webSocket.close(); } }; Connection.prototype.reopen = function reopen() { @@ -211,7 +211,9 @@ } }; Connection.prototype.getProtocol = function getProtocol() { - return this.webSocket ? this.webSocket.protocol : undefined; + if (this.webSocket) { + return this.webSocket.protocol; + } }; Connection.prototype.isOpen = function isOpen() { return this.isState("open"); @@ -452,16 +454,15 @@ }; return Consumer; }(); - function createConsumer(url) { - if (url == null) { - var urlConfig = getConfig("url"); - url = urlConfig ? urlConfig : INTERNAL.default_mount_path; - } + function createConsumer() { + var url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getConfig("url") || INTERNAL.default_mount_path; return new Consumer(createWebSocketURL(url)); } function getConfig(name) { var element = document.head.querySelector("meta[name='action-cable-" + name + "']"); - return element ? element.getAttribute("content") : undefined; + if (element) { + return element.getAttribute("content"); + } } function createWebSocketURL(url) { if (url && !/^wss?:/i.test(url)) { diff --git a/actioncable/app/javascript/action_cable/connection.js b/actioncable/app/javascript/action_cable/connection.js index b2910cb2a6..96bac132c1 100644 --- a/actioncable/app/javascript/action_cable/connection.js +++ b/actioncable/app/javascript/action_cable/connection.js @@ -44,7 +44,9 @@ class Connection { close({allowReconnect} = {allowReconnect: true}) { if (!allowReconnect) { this.monitor.stop() } - if (this.isActive()) { return (this.webSocket ? this.webSocket.close() : undefined) } + if (this.isActive()) { + return this.webSocket.close() + } } reopen() { @@ -65,7 +67,9 @@ class Connection { } getProtocol() { - return (this.webSocket ? this.webSocket.protocol : undefined) + if (this.webSocket) { + return this.webSocket.protocol + } } isOpen() { diff --git a/actioncable/app/javascript/action_cable/index.js b/actioncable/app/javascript/action_cable/index.js index 9f41c14e94..659418396f 100644 --- a/actioncable/app/javascript/action_cable/index.js +++ b/actioncable/app/javascript/action_cable/index.js @@ -18,17 +18,15 @@ export { logger, } -export function createConsumer(url) { - if (url == null) { - const urlConfig = getConfig("url") - url = (urlConfig ? urlConfig : INTERNAL.default_mount_path) - } +export function createConsumer(url = getConfig("url") || INTERNAL.default_mount_path) { return new Consumer(createWebSocketURL(url)) } export function getConfig(name) { const element = document.head.querySelector(`meta[name='action-cable-${name}']`) - return (element ? element.getAttribute("content") : undefined) + if (element) { + return element.getAttribute("content") + } } export function createWebSocketURL(url) { diff --git a/actioncable/lib/action_cable/connection.rb b/actioncable/lib/action_cable/connection.rb index 804b89a707..20b5dbe78d 100644 --- a/actioncable/lib/action_cable/connection.rb +++ b/actioncable/lib/action_cable/connection.rb @@ -15,6 +15,7 @@ module ActionCable autoload :StreamEventLoop autoload :Subscriptions autoload :TaggedLoggerProxy + autoload :TestCase autoload :WebSocket end end diff --git a/actioncable/lib/action_cable/connection/test_case.rb b/actioncable/lib/action_cable/connection/test_case.rb new file mode 100644 index 0000000000..26a183d1ec --- /dev/null +++ b/actioncable/lib/action_cable/connection/test_case.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +require "active_support" +require "active_support/test_case" +require "active_support/core_ext/hash/indifferent_access" +require "action_dispatch" +require "action_dispatch/http/headers" +require "action_dispatch/testing/test_request" + +module ActionCable + module Connection + class NonInferrableConnectionError < ::StandardError + def initialize(name) + super "Unable to determine the connection to test from #{name}. " + + "You'll need to specify it using `tests YourConnection` in your " + + "test case definition." + end + end + + module Assertions + # Asserts that the connection is rejected (via +reject_unauthorized_connection+). + # + # # Asserts that connection without user_id fails + # assert_reject_connection { connect params: { user_id: '' } } + def assert_reject_connection(&block) + assert_raises(Authorization::UnauthorizedError, "Expected to reject connection but no rejection was made", &block) + end + end + + # We don't want to use the whole "encryption stack" for connection + # unit-tests, but we want to make sure that users test against the correct types + # of cookies (i.e. signed or encrypted or plain) + class TestCookieJar < ActiveSupport::HashWithIndifferentAccess + def signed + self[:signed] ||= {}.with_indifferent_access + end + + def encrypted + self[:encrypted] ||= {}.with_indifferent_access + end + end + + class TestRequest < ActionDispatch::TestRequest + attr_accessor :session, :cookie_jar + + attr_writer :cookie_jar + end + + module TestConnection + attr_reader :logger, :request + + def initialize(request) + inner_logger = ActiveSupport::Logger.new(StringIO.new) + tagged_logging = ActiveSupport::TaggedLogging.new(inner_logger) + @logger = ActionCable::Connection::TaggedLoggerProxy.new(tagged_logging, tags: []) + @request = request + @env = request.env + end + end + + # Unit test Action Cable connections. + # + # Useful to check whether a connection's +identified_by+ gets assigned properly + # and that any improper connection requests are rejected. + # + # == Basic example + # + # Unit tests are written as follows: + # + # 1. Simulate a connection attempt by calling +connect+. + # 2. Assert state, e.g. identifiers, has been assigned. + # + # + # class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase + # def test_connects_with_proper_cookie + # # Simulate the connection request with a cookie. + # cookies["user_id"] = users(:john).id + # + # connect + # + # # Assert the connection identifier matches the fixture. + # assert_equal users(:john).id, connection.user.id + # end + # + # def test_rejects_connection_without_proper_cookie + # assert_reject_connection { connect } + # end + # end + # + # +connect+ accepts additional information the HTTP request with the + # +params+, +headers+, +session+ and Rack +env+ options. + # + # def test_connect_with_headers_and_query_string + # connect params: { user_id: 1 }, headers: { "X-API-TOKEN" => "secret-my" } + # + # assert_equal "1", connection.user.id + # assert_equal "secret-my", connection.token + # end + # + # def test_connect_with_params + # connect params: { user_id: 1 } + # + # assert_equal "1", connection.user.id + # end + # + # You can also setup the correct cookies before the connection request: + # + # def test_connect_with_cookies + # # Plain cookies: + # cookies["user_id"] = 1 + # + # # Or signed/encrypted: + # # cookies.signed["user_id"] = 1 + # # cookies.encrypted["user_id"] = 1 + # + # connect + # + # assert_equal "1", connection.user_id + # end + # + # == Connection is automatically inferred + # + # ActionCable::Connection::TestCase will automatically infer the connection under test + # from the test class name. If the channel cannot be inferred from the test + # class name, you can explicitly set it with +tests+. + # + # class ConnectionTest < ActionCable::Connection::TestCase + # tests ApplicationCable::Connection + # end + # + class TestCase < ActiveSupport::TestCase + module Behavior + extend ActiveSupport::Concern + + DEFAULT_PATH = "/cable" + + include ActiveSupport::Testing::ConstantLookup + include Assertions + + included do + class_attribute :_connection_class + + attr_reader :connection + + ActiveSupport.run_load_hooks(:action_cable_connection_test_case, self) + end + + module ClassMethods + def tests(connection) + case connection + when String, Symbol + self._connection_class = connection.to_s.camelize.constantize + when Module + self._connection_class = connection + else + raise NonInferrableConnectionError.new(connection) + end + end + + def connection_class + if connection = self._connection_class + connection + else + tests determine_default_connection(name) + end + end + + def determine_default_connection(name) + connection = determine_constant_from_test_name(name) do |constant| + Class === constant && constant < ActionCable::Connection::Base + end + raise NonInferrableConnectionError.new(name) if connection.nil? + connection + end + end + + # Performs connection attempt to exert #connect on the connection under test. + # + # Accepts request path as the first argument and the following request options: + # + # - params – url parameters (Hash) + # - headers – request headers (Hash) + # - session – session data (Hash) + # - env – additional Rack env configuration (Hash) + def connect(path = ActionCable.server.config.mount_path, **request_params) + path ||= DEFAULT_PATH + + connection = self.class.connection_class.allocate + connection.singleton_class.include(TestConnection) + connection.send(:initialize, build_test_request(path, request_params)) + connection.connect if connection.respond_to?(:connect) + + # Only set instance variable if connected successfully + @connection = connection + end + + # Exert #disconnect on the connection under test. + def disconnect + raise "Must be connected!" if connection.nil? + + connection.disconnect if connection.respond_to?(:disconnect) + @connection = nil + end + + def cookies + @cookie_jar ||= TestCookieJar.new + end + + private + def build_test_request(path, params: nil, headers: {}, session: {}, env: {}) + wrapped_headers = ActionDispatch::Http::Headers.from_hash(headers) + + uri = URI.parse(path) + + query_string = params.nil? ? uri.query : params.to_query + + request_env = { + "QUERY_STRING" => query_string, + "PATH_INFO" => uri.path + }.merge(env) + + if wrapped_headers.present? + ActionDispatch::Http::Headers.from_hash(request_env).merge!(wrapped_headers) + end + + TestRequest.create(request_env).tap do |request| + request.session = session.with_indifferent_access + request.cookie_jar = cookies + end + end + end + + include Behavior + end + end +end diff --git a/actioncable/lib/rails/generators/channel/USAGE b/actioncable/lib/rails/generators/channel/USAGE index ea9662436c..bb5dd7e2db 100644 --- a/actioncable/lib/rails/generators/channel/USAGE +++ b/actioncable/lib/rails/generators/channel/USAGE @@ -7,6 +7,7 @@ Example: ======== rails generate channel Chat speak - creates a Chat channel class and JavaScript asset: + creates a Chat channel class, test and JavaScript asset: Channel: app/channels/chat_channel.rb + Test: test/channels/chat_channel_test.rb Assets: app/javascript/channels/chat_channel.js diff --git a/actioncable/lib/rails/generators/channel/channel_generator.rb b/actioncable/lib/rails/generators/channel/channel_generator.rb index ef51981e89..0b80d1f96b 100644 --- a/actioncable/lib/rails/generators/channel/channel_generator.rb +++ b/actioncable/lib/rails/generators/channel/channel_generator.rb @@ -11,6 +11,8 @@ module Rails check_class_collision suffix: "Channel" + hook_for :test_framework + def create_channel_file template "channel.rb", File.join("app/channels", class_path, "#{file_name}_channel.rb") diff --git a/actioncable/lib/rails/generators/test_unit/channel_generator.rb b/actioncable/lib/rails/generators/test_unit/channel_generator.rb new file mode 100644 index 0000000000..7d13a12f0a --- /dev/null +++ b/actioncable/lib/rails/generators/test_unit/channel_generator.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module TestUnit + module Generators + class ChannelGenerator < ::Rails::Generators::NamedBase + source_root File.expand_path("templates", __dir__) + + check_class_collision suffix: "ChannelTest" + + def create_test_files + template "channel_test.rb", File.join("test/channels", class_path, "#{file_name}_channel_test.rb") + end + + private + def file_name # :doc: + @_file_name ||= super.sub(/_channel\z/i, "") + end + end + end +end diff --git a/actioncable/lib/rails/generators/test_unit/templates/channel_test.rb.tt b/actioncable/lib/rails/generators/test_unit/templates/channel_test.rb.tt new file mode 100644 index 0000000000..301dc0b6fe --- /dev/null +++ b/actioncable/lib/rails/generators/test_unit/templates/channel_test.rb.tt @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "test_helper" + +class <%= class_name %>ChannelTest < ActionCable::Channel::TestCase + # test "subscribes" do + # subscribe + # assert subscription.confirmed? + # end +end diff --git a/actioncable/test/connection/test_case_test.rb b/actioncable/test/connection/test_case_test.rb new file mode 100644 index 0000000000..3b19465d7b --- /dev/null +++ b/actioncable/test/connection/test_case_test.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +require "test_helper" + +class SimpleConnection < ActionCable::Connection::Base + identified_by :user_id + + class << self + attr_accessor :disconnected_user_id + end + + def connect + self.user_id = request.params[:user_id] || cookies[:user_id] + end + + def disconnect + self.class.disconnected_user_id = user_id + end +end + +class ConnectionSimpleTest < ActionCable::Connection::TestCase + tests SimpleConnection + + def test_connected + connect + + assert_nil connection.user_id + end + + def test_url_params + connect "/cable?user_id=323" + + assert_equal "323", connection.user_id + end + + def test_params + connect params: { user_id: 323 } + + assert_equal "323", connection.user_id + end + + def test_plain_cookie + cookies["user_id"] = "456" + + connect + + assert_equal "456", connection.user_id + end + + def test_disconnect + cookies["user_id"] = "456" + + connect + + assert_equal "456", connection.user_id + + disconnect + + assert_equal "456", SimpleConnection.disconnected_user_id + end +end + +class Connection < ActionCable::Connection::Base + identified_by :current_user_id + identified_by :token + + class << self + attr_accessor :disconnected_user_id + end + + def connect + self.current_user_id = verify_user + self.token = request.headers["X-API-TOKEN"] + logger.add_tags("ActionCable") + end + + private + def verify_user + cookies.signed[:user_id].presence || reject_unauthorized_connection + end +end + +class ConnectionTest < ActionCable::Connection::TestCase + def test_connected_with_signed_cookies_and_headers + cookies.signed["user_id"] = "456" + + connect headers: { "X-API-TOKEN" => "abc" } + + assert_equal "abc", connection.token + assert_equal "456", connection.current_user_id + end + + def test_connected_when_no_signed_cookies_set + cookies["user_id"] = "456" + + assert_reject_connection { connect } + end + + def test_connection_rejected + assert_reject_connection { connect } + end + + def test_connection_rejected_assertion_message + error = assert_raises Minitest::Assertion do + assert_reject_connection { "Intentionally doesn't connect." } + end + + assert_match(/Expected to reject connection/, error.message) + end +end + +class EncryptedCookiesConnection < ActionCable::Connection::Base + identified_by :user_id + + def connect + self.user_id = verify_user + end + + private + def verify_user + cookies.encrypted[:user_id].presence || reject_unauthorized_connection + end +end + +class EncryptedCookiesConnectionTest < ActionCable::Connection::TestCase + tests EncryptedCookiesConnection + + def test_connected_with_encrypted_cookies + cookies.encrypted["user_id"] = "456" + + connect + + assert_equal "456", connection.user_id + end + + def test_connection_rejected + assert_reject_connection { connect } + end +end + +class SessionConnection < ActionCable::Connection::Base + identified_by :user_id + + def connect + self.user_id = verify_user + end + + private + def verify_user + request.session[:user_id].presence || reject_unauthorized_connection + end +end + +class SessionConnectionTest < ActionCable::Connection::TestCase + tests SessionConnection + + def test_connected_with_encrypted_cookies + connect session: { user_id: "789" } + assert_equal "789", connection.user_id + end + + def test_connection_rejected + assert_reject_connection { connect } + end +end + +class EnvConnection < ActionCable::Connection::Base + identified_by :user + + def connect + self.user = verify_user + end + + private + def verify_user + # Warden-like authentication + env["authenticator"]&.user || reject_unauthorized_connection + end +end + +class EnvConnectionTest < ActionCable::Connection::TestCase + tests EnvConnection + + def test_connected_with_env + authenticator = Class.new do + def user; "David"; end + end + + connect env: { "authenticator" => authenticator.new } + + assert_equal "David", connection.user + end + + def test_connection_rejected + assert_reject_connection { connect } + end +end diff --git a/actionmailbox/README.md b/actionmailbox/README.md index c70f73b40f..9a47223d3b 100644 --- a/actionmailbox/README.md +++ b/actionmailbox/README.md @@ -1,6 +1,6 @@ # Action Mailbox -Action Mailbox routes incoming emails to controller-like mailboxes for processing in Rails. It ships with ingresses for Amazon SES, Mailgun, Mandrill, Postmark, and SendGrid. You can also handle inbound mails directly via the built-in Postfix ingress. +Action Mailbox routes incoming emails to controller-like mailboxes for processing in Rails. It ships with ingresses for Amazon SES, Mailgun, Mandrill, Postmark, and SendGrid. You can also handle inbound mails directly via the built-in Exim, Postfix, and Qmail ingresses. The inbound emails are turned into `InboundEmail` records using Active Record and feature lifecycle tracking, storage of the original email on cloud storage via Active Storage, and responsible data handling with on-by-default incineration. diff --git a/actionmailbox/app/controllers/action_mailbox/ingresses/postfix/inbound_emails_controller.rb b/actionmailbox/app/controllers/action_mailbox/ingresses/relay/inbound_emails_controller.rb index c0d5002a12..2d91c968c8 100644 --- a/actionmailbox/app/controllers/action_mailbox/ingresses/postfix/inbound_emails_controller.rb +++ b/actionmailbox/app/controllers/action_mailbox/ingresses/relay/inbound_emails_controller.rb @@ -1,31 +1,31 @@ # frozen_string_literal: true module ActionMailbox - # Ingests inbound emails relayed from Postfix. + # Ingests inbound emails relayed from an SMTP server. # # Authenticates requests using HTTP basic access authentication. The username is always +actionmailbox+, and the # password is read from the application's encrypted credentials or an environment variable. See the Usage section below. # # Note that basic authentication is insecure over unencrypted HTTP. An attacker that intercepts cleartext requests to - # the Postfix ingress can learn its password. You should only use the Postfix ingress over HTTPS. + # the ingress can learn its password. You should only use this ingress over HTTPS. # # Returns: # # - <tt>204 No Content</tt> if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox # - <tt>401 Unauthorized</tt> if the request could not be authenticated - # - <tt>404 Not Found</tt> if Action Mailbox is not configured to accept inbound emails from Postfix + # - <tt>404 Not Found</tt> if Action Mailbox is not configured to accept inbound emails relayed from an SMTP server # - <tt>415 Unsupported Media Type</tt> if the request does not contain an RFC 822 message # - <tt>500 Server Error</tt> if the ingress password is not configured, or if one of the Active Record database, # the Active Storage service, or the Active Job backend is misconfigured or unavailable # # == Usage # - # 1. Tell Action Mailbox to accept emails from Postfix: + # 1. Tell Action Mailbox to accept emails from an SMTP relay: # # # config/environments/production.rb - # config.action_mailbox.ingress = :postfix + # config.action_mailbox.ingress = :relay # - # 2. Generate a strong password that Action Mailbox can use to authenticate requests to the Postfix ingress. + # 2. Generate a strong password that Action Mailbox can use to authenticate requests to the ingress. # # Use <tt>rails credentials:edit</tt> to add the password to your application's encrypted credentials under # +action_mailbox.ingress_password+, where Action Mailbox will automatically find it: @@ -35,14 +35,20 @@ module ActionMailbox # # Alternatively, provide the password in the +RAILS_INBOUND_EMAIL_PASSWORD+ environment variable. # - # 3. {Configure Postfix}[https://serverfault.com/questions/258469/how-to-configure-postfix-to-pipe-all-incoming-email-to-a-script] - # to pipe inbound emails to <tt>bin/rails action_mailbox:ingress:postfix</tt>, providing the +URL+ of the Postfix - # ingress and the +INGRESS_PASSWORD+ you previously generated. + # 3. Configure your SMTP server to pipe inbound emails to the appropriate ingress command, providing the +URL+ of the + # relay ingress and the +INGRESS_PASSWORD+ you previously generated. # - # If your application lived at <tt>https://example.com</tt>, the full command would look like this: + # If your application lives at <tt>https://example.com</tt>, you would configure the Postfix SMTP server to pipe + # inbound emails to the following command: # - # URL=https://example.com/rails/action_mailbox/postfix/inbound_emails INGRESS_PASSWORD=... bin/rails action_mailbox:ingress:postfix - class Ingresses::Postfix::InboundEmailsController < ActionMailbox::BaseController + # bin/rails action_mailbox:ingress:postfix URL=https://example.com/rails/action_mailbox/postfix/inbound_emails INGRESS_PASSWORD=... + # + # Built-in ingress commands are available for these popular SMTP servers: + # + # - Exim (<tt>bin/rails action_mailbox:ingress:exim) + # - Postfix (<tt>bin/rails action_mailbox:ingress:postfix) + # - Qmail (<tt>bin/rails action_mailbox:ingress:qmail) + class Ingresses::Relay::InboundEmailsController < ActionMailbox::BaseController before_action :authenticate_by_password, :require_valid_rfc822_message def create diff --git a/actionmailbox/config/routes.rb b/actionmailbox/config/routes.rb index 7aa230fdff..517d2835af 100644 --- a/actionmailbox/config/routes.rb +++ b/actionmailbox/config/routes.rb @@ -4,8 +4,8 @@ Rails.application.routes.draw do scope "/rails/action_mailbox", module: "action_mailbox/ingresses" do post "/amazon/inbound_emails" => "amazon/inbound_emails#create", as: :rails_amazon_inbound_emails post "/mandrill/inbound_emails" => "mandrill/inbound_emails#create", as: :rails_mandrill_inbound_emails - post "/postfix/inbound_emails" => "postfix/inbound_emails#create", as: :rails_postfix_inbound_emails post "/postmark/inbound_emails" => "postmark/inbound_emails#create", as: :rails_postmark_inbound_emails + post "/relay/inbound_emails" => "relay/inbound_emails#create", as: :rails_relay_inbound_emails post "/sendgrid/inbound_emails" => "sendgrid/inbound_emails#create", as: :rails_sendgrid_inbound_emails # Mailgun requires that a webhook's URL end in 'mime' for it to receive the raw contents of emails. diff --git a/actionmailbox/lib/action_mailbox/postfix_relayer.rb b/actionmailbox/lib/action_mailbox/relayer.rb index d43c56ed2b..e2890acb60 100644 --- a/actionmailbox/lib/action_mailbox/postfix_relayer.rb +++ b/actionmailbox/lib/action_mailbox/relayer.rb @@ -5,19 +5,27 @@ require "net/http" require "uri" module ActionMailbox - class PostfixRelayer - class Result < Struct.new(:output) + class Relayer + class Result < Struct.new(:status_code, :message) def success? !failure? end def failure? - output.match?(/\A[45]\.\d{1,3}\.\d{1,3}(\s|\z)/) + transient_failure? || permanent_failure? + end + + def transient_failure? + status_code.start_with?("4.") + end + + def permanent_failure? + status_code.start_with?("5.") end end CONTENT_TYPE = "message/rfc822" - USER_AGENT = "Action Mailbox Postfix relayer v#{ActionMailbox.version}" + USER_AGENT = "Action Mailbox relayer v#{ActionMailbox.version}" attr_reader :uri, :username, :password @@ -28,18 +36,18 @@ module ActionMailbox def relay(source) case response = post(source) when Net::HTTPSuccess - Result.new "2.0.0 Successfully relayed message to Postfix ingress" + Result.new "2.0.0", "Successfully relayed message to ingress" when Net::HTTPUnauthorized - Result.new "4.7.0 Invalid credentials for Postfix ingress" + Result.new "4.7.0", "Invalid credentials for ingress" else - Result.new "4.0.0 HTTP #{response.code}" + Result.new "4.0.0", "HTTP #{response.code}" end rescue IOError, SocketError, SystemCallError => error - Result.new "4.4.2 Network error relaying to Postfix ingress: #{error.message}" + Result.new "4.4.2", "Network error relaying to ingress: #{error.message}" rescue Timeout::Error - Result.new "4.4.2 Timed out relaying to Postfix ingress" + Result.new "4.4.2", "Timed out relaying to ingress" rescue => error - Result.new "4.0.0 Error relaying to Postfix ingress: #{error.message}" + Result.new "4.0.0", "Error relaying to ingress: #{error.message}" end private diff --git a/actionmailbox/lib/tasks/ingress.rake b/actionmailbox/lib/tasks/ingress.rake index f775bbdfd7..43b613ea12 100644 --- a/actionmailbox/lib/tasks/ingress.rake +++ b/actionmailbox/lib/tasks/ingress.rake @@ -2,12 +2,37 @@ namespace :action_mailbox do namespace :ingress do - desc "Pipe an inbound email from STDIN to the Postfix ingress (URL and INGRESS_PASSWORD required)" - task :postfix do + task :environment do require "active_support" require "active_support/core_ext/object/blank" - require "action_mailbox/postfix_relayer" + require "action_mailbox/relayer" + end + + desc "Relay an inbound email from Exim to Action Mailbox (URL and INGRESS_PASSWORD required)" + task exim: "action_mailbox:ingress:environment" do + url, password = ENV.values_at("URL", "INGRESS_PASSWORD") + + if url.blank? || password.blank? + print "URL and INGRESS_PASSWORD are required" + exit 64 # EX_USAGE + end + + ActionMailbox::Relayer.new(url: url, password: password).relay(STDIN.read).tap do |result| + print result.message + + case + when result.success? + exit 0 + when result.transient_failure? + exit 75 # EX_TEMPFAIL + else + exit 69 # EX_UNAVAILABLE + end + end + end + desc "Relay an inbound email from Postfix to Action Mailbox (URL and INGRESS_PASSWORD required)" + task postfix: "action_mailbox:ingress:environment" do url, password = ENV.values_at("URL", "INGRESS_PASSWORD") if url.blank? || password.blank? @@ -15,10 +40,33 @@ namespace :action_mailbox do exit 1 end - ActionMailbox::PostfixRelayer.new(url: url, password: password).relay(STDIN.read).tap do |result| - print result.output + ActionMailbox::Relayer.new(url: url, password: password).relay(STDIN.read).tap do |result| + print "#{result.status_code} #{result.message}" exit result.success? end end + + desc "Relay an inbound email from Qmail to Action Mailbox (URL and INGRESS_PASSWORD required)" + task qmail: "action_mailbox:ingress:environment" do + url, password = ENV.values_at("URL", "INGRESS_PASSWORD") + + if url.blank? || password.blank? + print "URL and INGRESS_PASSWORD are required" + exit 111 + end + + ActionMailbox::Relayer.new(url: url, password: password).relay(STDIN.read).tap do |result| + print result.message + + case + when result.success? + exit 0 + when result.transient_failure? + exit 111 + else + exit 100 + end + end + end end end diff --git a/actionmailbox/test/controllers/ingresses/postfix/inbound_emails_controller_test.rb b/actionmailbox/test/controllers/ingresses/relay/inbound_emails_controller_test.rb index d646f5e859..67c5993f7f 100644 --- a/actionmailbox/test/controllers/ingresses/postfix/inbound_emails_controller_test.rb +++ b/actionmailbox/test/controllers/ingresses/relay/inbound_emails_controller_test.rb @@ -2,12 +2,12 @@ require "test_helper" -class ActionMailbox::Ingresses::Postfix::InboundEmailsControllerTest < ActionDispatch::IntegrationTest - setup { ActionMailbox.ingress = :postfix } +class ActionMailbox::Ingresses::Relay::InboundEmailsControllerTest < ActionDispatch::IntegrationTest + setup { ActionMailbox.ingress = :relay } - test "receiving an inbound email from Postfix" do + test "receiving an inbound email relayed from an SMTP server" do assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do - post rails_postfix_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "message/rfc822" }, + post rails_relay_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "message/rfc822" }, params: file_fixture("../files/welcome.eml").read end @@ -18,18 +18,18 @@ class ActionMailbox::Ingresses::Postfix::InboundEmailsControllerTest < ActionDis assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id end - test "rejecting an unauthorized inbound email from Postfix" do + test "rejecting an unauthorized inbound email" do assert_no_difference -> { ActionMailbox::InboundEmail.count } do - post rails_postfix_inbound_emails_url, headers: { "Content-Type" => "message/rfc822" }, + post rails_relay_inbound_emails_url, headers: { "Content-Type" => "message/rfc822" }, params: file_fixture("../files/welcome.eml").read end assert_response :unauthorized end - test "rejecting an inbound email of an unsupported media type from Postfix" do + test "rejecting an inbound email of an unsupported media type" do assert_no_difference -> { ActionMailbox::InboundEmail.count } do - post rails_postfix_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "text/plain" }, + post rails_relay_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "text/plain" }, params: file_fixture("../files/welcome.eml").read end @@ -39,7 +39,7 @@ class ActionMailbox::Ingresses::Postfix::InboundEmailsControllerTest < ActionDis test "raising when the configured password is nil" do switch_password_to nil do assert_raises ArgumentError do - post rails_postfix_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "message/rfc822" }, + post rails_relay_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "message/rfc822" }, params: file_fixture("../files/welcome.eml").read end end @@ -48,7 +48,7 @@ class ActionMailbox::Ingresses::Postfix::InboundEmailsControllerTest < ActionDis test "raising when the configured password is blank" do switch_password_to "" do assert_raises ArgumentError do - post rails_postfix_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "message/rfc822" }, + post rails_relay_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "message/rfc822" }, params: file_fixture("../files/welcome.eml").read end end diff --git a/actionmailbox/test/unit/postfix_relayer_test.rb b/actionmailbox/test/unit/relayer_test.rb index 5f7496ec3f..fb2b48ea16 100644 --- a/actionmailbox/test/unit/postfix_relayer_test.rb +++ b/actionmailbox/test/unit/relayer_test.rb @@ -2,35 +2,37 @@ require_relative "../test_helper" -require "action_mailbox/postfix_relayer" +require "action_mailbox/relayer" module ActionMailbox - class PostfixRelayerTest < ActiveSupport::TestCase - URL = "https://example.com/rails/action_mailbox/postfix/inbound_emails" + class RelayerTest < ActiveSupport::TestCase + URL = "https://example.com/rails/action_mailbox/relay/inbound_emails" INGRESS_PASSWORD = "secret" setup do - @relayer = ActionMailbox::PostfixRelayer.new(url: URL, password: INGRESS_PASSWORD) + @relayer = ActionMailbox::Relayer.new(url: URL, password: INGRESS_PASSWORD) end test "successfully relaying an email" do stub_request(:post, URL).to_return status: 204 result = @relayer.relay(file_fixture("welcome.eml").read) - assert_equal "2.0.0 Successfully relayed message to Postfix ingress", result.output + assert_equal "2.0.0", result.status_code + assert_equal "Successfully relayed message to ingress", result.message assert result.success? assert_not result.failure? assert_requested :post, URL, body: file_fixture("welcome.eml").read, basic_auth: [ "actionmailbox", INGRESS_PASSWORD ], - headers: { "Content-Type" => "message/rfc822", "User-Agent" => /\AAction Mailbox Postfix relayer v\d+\./ } + headers: { "Content-Type" => "message/rfc822", "User-Agent" => /\AAction Mailbox relayer v\d+\./ } end test "unsuccessfully relaying with invalid credentials" do stub_request(:post, URL).to_return status: 401 result = @relayer.relay(file_fixture("welcome.eml").read) - assert_equal "4.7.0 Invalid credentials for Postfix ingress", result.output + assert_equal "4.7.0", result.status_code + assert_equal "Invalid credentials for ingress", result.message assert_not result.success? assert result.failure? end @@ -39,7 +41,8 @@ module ActionMailbox stub_request(:post, URL).to_return status: 500 result = @relayer.relay(file_fixture("welcome.eml").read) - assert_equal "4.0.0 HTTP 500", result.output + assert_equal "4.0.0", result.status_code + assert_equal "HTTP 500", result.message assert_not result.success? assert result.failure? end @@ -48,7 +51,8 @@ module ActionMailbox stub_request(:post, URL).to_return status: 504 result = @relayer.relay(file_fixture("welcome.eml").read) - assert_equal "4.0.0 HTTP 504", result.output + assert_equal "4.0.0", result.status_code + assert_equal "HTTP 504", result.message assert_not result.success? assert result.failure? end @@ -57,7 +61,8 @@ module ActionMailbox stub_request(:post, URL).to_raise Errno::ECONNRESET.new result = @relayer.relay(file_fixture("welcome.eml").read) - assert_equal "4.4.2 Network error relaying to Postfix ingress: Connection reset by peer", result.output + assert_equal "4.4.2", result.status_code + assert_equal "Network error relaying to ingress: Connection reset by peer", result.message assert_not result.success? assert result.failure? end @@ -66,7 +71,8 @@ module ActionMailbox stub_request(:post, URL).to_raise SocketError.new("Failed to open TCP connection to example.com:443") result = @relayer.relay(file_fixture("welcome.eml").read) - assert_equal "4.4.2 Network error relaying to Postfix ingress: Failed to open TCP connection to example.com:443", result.output + assert_equal "4.4.2", result.status_code + assert_equal "Network error relaying to ingress: Failed to open TCP connection to example.com:443", result.message assert_not result.success? assert result.failure? end @@ -75,7 +81,8 @@ module ActionMailbox stub_request(:post, URL).to_timeout result = @relayer.relay(file_fixture("welcome.eml").read) - assert_equal "4.4.2 Timed out relaying to Postfix ingress", result.output + assert_equal "4.4.2", result.status_code + assert_equal "Timed out relaying to ingress", result.message assert_not result.success? assert result.failure? end @@ -84,7 +91,8 @@ module ActionMailbox stub_request(:post, URL).to_raise StandardError.new("Something went wrong") result = @relayer.relay(file_fixture("welcome.eml").read) - assert_equal "4.0.0 Error relaying to Postfix ingress: Something went wrong", result.output + assert_equal "4.0.0", result.status_code + assert_equal "Error relaying to ingress: Something went wrong", result.message assert_not result.success? assert result.failure? end diff --git a/activerecord/lib/arel/predications.rb b/activerecord/lib/arel/predications.rb index 0c03e93138..2a62c53aa3 100644 --- a/activerecord/lib/arel/predications.rb +++ b/activerecord/lib/arel/predications.rb @@ -82,14 +82,14 @@ Passing a range to `#in` is deprecated. Call `#between`, instead. def not_between(other) if infinity?(other.begin) - if infinity?(other.end) + if other.end.nil? || infinity?(other.end) self.in([]) elsif other.exclude_end? gteq(other.end) else gt(other.end) end - elsif infinity?(other.end) + elsif other.end.nil? || infinity?(other.end) lt(other.begin) else left = lt(other.begin) diff --git a/activerecord/test/cases/arel/attributes/attribute_test.rb b/activerecord/test/cases/arel/attributes/attribute_test.rb index 6ad54ab5df..c7bd0a053b 100644 --- a/activerecord/test/cases/arel/attributes/attribute_test.rb +++ b/activerecord/test/cases/arel/attributes/attribute_test.rb @@ -560,7 +560,7 @@ module Arel end end - describe "with a range" do + describe "#between" do it "can be constructed with a standard range" do attribute = Attribute.new nil, nil node = attribute.between(1..3) @@ -628,7 +628,6 @@ module Arel node.must_equal Nodes::NotIn.new(attribute, []) end - it "can be constructed with a range ending at Infinity" do attribute = Attribute.new nil, nil node = attribute.between(0..::Float::INFINITY) @@ -676,14 +675,6 @@ module Arel ) ]) end - - def quoted_range(begin_val, end_val, exclude) - OpenStruct.new( - begin: Nodes::Quoted.new(begin_val), - end: Nodes::Quoted.new(end_val), - exclude_end?: exclude, - ) - end end describe "#in" do @@ -765,21 +756,23 @@ module Arel end end - describe "with a range" do + describe "#not_between" do it "can be constructed with a standard range" do attribute = Attribute.new nil, nil node = attribute.not_between(1..3) - node.must_equal Nodes::Grouping.new(Nodes::Or.new( - Nodes::LessThan.new( - attribute, - Nodes::Casted.new(1, attribute) - ), - Nodes::GreaterThan.new( - attribute, - Nodes::Casted.new(3, attribute) + node.must_equal Nodes::Grouping.new( + Nodes::Or.new( + Nodes::LessThan.new( + attribute, + Nodes::Casted.new(1, attribute) + ), + Nodes::GreaterThan.new( + attribute, + Nodes::Casted.new(3, attribute) + ) ) - )) + ) end it "can be constructed with a range starting from -Infinity" do @@ -792,6 +785,16 @@ module Arel ) end + it "can be constructed with a quoted range starting from -Infinity" do + attribute = Attribute.new nil, nil + node = attribute.not_between(quoted_range(-::Float::INFINITY, 3, false)) + + node.must_equal Nodes::GreaterThan.new( + attribute, + Nodes::Quoted.new(3) + ) + end + it "can be constructed with an exclusive range starting from -Infinity" do attribute = Attribute.new nil, nil node = attribute.not_between(-::Float::INFINITY...3) @@ -802,6 +805,16 @@ module Arel ) end + it "can be constructed with a quoted exclusive range starting from -Infinity" do + attribute = Attribute.new nil, nil + node = attribute.not_between(quoted_range(-::Float::INFINITY, 3, true)) + + node.must_equal Nodes::GreaterThanOrEqual.new( + attribute, + Nodes::Quoted.new(3) + ) + end + it "can be constructed with an infinite range" do attribute = Attribute.new nil, nil node = attribute.not_between(-::Float::INFINITY..::Float::INFINITY) @@ -809,6 +822,13 @@ module Arel node.must_equal Nodes::In.new(attribute, []) end + it "can be constructed with a quoted infinite range" do + attribute = Attribute.new nil, nil + node = attribute.not_between(quoted_range(-::Float::INFINITY, ::Float::INFINITY, false)) + + node.must_equal Nodes::In.new(attribute, []) + end + it "can be constructed with a range ending at Infinity" do attribute = Attribute.new nil, nil node = attribute.not_between(0..::Float::INFINITY) @@ -819,20 +839,44 @@ module Arel ) end + if Gem::Version.new("2.6.0") <= Gem::Version.new(RUBY_VERSION) + it "can be constructed with a range implicitly ending at Infinity" do + attribute = Attribute.new nil, nil + node = attribute.not_between(eval("0..")) # Use eval for compatibility with Ruby < 2.6 parser + + node.must_equal Nodes::LessThan.new( + attribute, + Nodes::Casted.new(0, attribute) + ) + end + end + + it "can be constructed with a quoted range ending at Infinity" do + attribute = Attribute.new nil, nil + node = attribute.not_between(quoted_range(0, ::Float::INFINITY, false)) + + node.must_equal Nodes::LessThan.new( + attribute, + Nodes::Quoted.new(0) + ) + end + it "can be constructed with an exclusive range" do attribute = Attribute.new nil, nil node = attribute.not_between(0...3) - node.must_equal Nodes::Grouping.new(Nodes::Or.new( - Nodes::LessThan.new( - attribute, - Nodes::Casted.new(0, attribute) - ), - Nodes::GreaterThanOrEqual.new( - attribute, - Nodes::Casted.new(3, attribute) + node.must_equal Nodes::Grouping.new( + Nodes::Or.new( + Nodes::LessThan.new( + attribute, + Nodes::Casted.new(0, attribute) + ), + Nodes::GreaterThanOrEqual.new( + attribute, + Nodes::Casted.new(3, attribute) + ) ) - )) + ) end end @@ -1022,6 +1066,15 @@ module Arel condition.to_sql.must_equal %("foo"."id" = (select 1)) end end + + private + def quoted_range(begin_val, end_val, exclude) + OpenStruct.new( + begin: Nodes::Quoted.new(begin_val), + end: Nodes::Quoted.new(end_val), + exclude_end?: exclude, + ) + end end end end diff --git a/activesupport/lib/active_support/core_ext/date_and_time/calculations.rb b/activesupport/lib/active_support/core_ext/date_and_time/calculations.rb index 05abd83221..e2e11545e2 100644 --- a/activesupport/lib/active_support/core_ext/date_and_time/calculations.rb +++ b/activesupport/lib/active_support/core_ext/date_and_time/calculations.rb @@ -134,7 +134,7 @@ module DateAndTime # now = DateTime.current # => Fri, 10 Jul 2015 18:41:29 +0000 # now.beginning_of_quarter # => Wed, 01 Jul 2015 00:00:00 +0000 def beginning_of_quarter - first_quarter_month = [10, 7, 4, 1].detect { |m| m <= month } + first_quarter_month = month - (2 + month) % 3 beginning_of_month.change(month: first_quarter_month) end alias :at_beginning_of_quarter :beginning_of_quarter @@ -149,7 +149,7 @@ module DateAndTime # now = DateTime.current # => Fri, 10 Jul 2015 18:41:29 +0000 # now.end_of_quarter # => Wed, 30 Sep 2015 23:59:59 +0000 def end_of_quarter - last_quarter_month = [3, 6, 9, 12].detect { |m| m >= month } + last_quarter_month = month + (12 - month) % 3 beginning_of_month.change(month: last_quarter_month).end_of_month end alias :at_end_of_quarter :end_of_quarter diff --git a/guides/source/6_0_release_notes.md b/guides/source/6_0_release_notes.md index 6f1db126c3..14528d1cde 100644 --- a/guides/source/6_0_release_notes.md +++ b/guides/source/6_0_release_notes.md @@ -8,6 +8,7 @@ Highlights in Rails 6.0: * Action Mailbox * Action Text * Parallel Testing +* Action Cable Testing These release notes cover only the major changes. To learn about various bug fixes and changes, please refer to the change logs or check out the [list of @@ -62,6 +63,13 @@ test suite. While forking processes is the default method, threading is supported as well. Running tests in parallel reduces the time it takes your entire test suite to run. +### Action Cable Testing + +[Pull Request](https://github.com/rails/rails/pull/33659) + +[Action Cable testing tools](testing.html#testing-action-cable) allow you to test your +Action Cable functionality at any level: connections, channels, broadcasts. + Railties -------- diff --git a/guides/source/action_cable_overview.md b/guides/source/action_cable_overview.md index 77a1b73bae..df02d5bd91 100644 --- a/guides/source/action_cable_overview.md +++ b/guides/source/action_cable_overview.md @@ -27,6 +27,36 @@ client-side JavaScript framework and a server-side Ruby framework. You have access to your full domain model written with Active Record or your ORM of choice. +Terminology +----------- + +A single Action Cable server can handle multiple connection instances. It has one +connection instance per WebSocket connection. A single user may have multiple +WebSockets open to your application if they use multiple browser tabs or devices. +The client of a WebSocket connection is called the consumer. + +Each consumer can in turn subscribe to multiple cable channels. Each channel +encapsulates a logical unit of work, similar to what a controller does in +a regular MVC setup. For example, you could have a `ChatChannel` and +an `AppearancesChannel`, and a consumer could be subscribed to either +or to both of these channels. At the very least, a consumer should be subscribed +to one channel. + +When the consumer is subscribed to a channel, they act as a subscriber. +The connection between the subscriber and the channel is, surprise-surprise, +called a subscription. A consumer can act as a subscriber to a given channel +any number of times. For example, a consumer could subscribe to multiple chat rooms +at the same time. (And remember that a physical user may have multiple consumers, +one per tab/device open to your connection). + +Each channel can then again be streaming zero or more broadcastings. +A broadcasting is a pubsub link where anything transmitted by the broadcaster is +sent directly to the channel subscribers who are streaming that named broadcasting. + +As you can see, this is a fairly deep architectural stack. There's a lot of new +terminology to identify the new pieces, and on top of that, you're dealing +with both client and server side reflections of each unit. + What is Pub/Sub --------------- @@ -165,12 +195,12 @@ you're interested in having. A consumer becomes a subscriber by creating a subscription to a given channel: ```js -// app/javascript/cable/subscriptions/chat_channel.js +// app/javascript/channels/chat_channel.js import consumer from "./consumer" consumer.subscriptions.create({ channel: "ChatChannel", room: "Best Room" }) -// app/javascript/cable/subscriptions/appearance_channel.js +// app/javascript/channels/appearance_channel.js import consumer from "./consumer" consumer.subscriptions.create({ channel: "AppearanceChannel" }) @@ -183,7 +213,7 @@ A consumer can act as a subscriber to a given channel any number of times. For example, a consumer could subscribe to multiple chat rooms at the same time: ```js -// app/javascript/cable/subscriptions/chat_channel.js +// app/javascript/channels/chat_channel.js import consumer from "./consumer" consumer.subscriptions.create({ channel: "ChatChannel", room: "1st Room" }) @@ -261,7 +291,7 @@ connection is called a subscription. Incoming messages are then routed to these channel subscriptions based on an identifier sent by the cable consumer. ```js -// app/javascript/cable/subscriptions/chat_channel.js +// app/javascript/channels/chat_channel.js // Assumes you've already requested the right to send web notifications import consumer from "./consumer" @@ -305,7 +335,7 @@ An object passed as the first argument to `subscriptions.create` becomes the params hash in the cable channel. The keyword `channel` is required: ```js -// app/javascript/cable/subscriptions/chat_channel.js +// app/javascript/channels/chat_channel.js import consumer from "./consumer" consumer.subscriptions.create({ channel: "ChatChannel", room: "Best Room" }, { @@ -359,7 +389,7 @@ end ``` ```js -// app/javascript/cable/subscriptions/chat_channel.js +// app/javascript/channels/chat_channel.js import consumer from "./consumer" const chatChannel = consumer.subscriptions.create({ channel: "ChatChannel", room: "Best Room" }, { @@ -419,7 +449,7 @@ appear/disappear API could be backed by Redis, a database, or whatever else. Create the client-side appearance channel subscription: ```js -// app/javascript/cable/subscriptions/appearance_channel.js +// app/javascript/channels/appearance_channel.js import consumer from "./consumer" consumer.subscriptions.create("AppearanceChannel", { @@ -534,7 +564,7 @@ end Create the client-side web notifications channel subscription: ```js -// app/javascript/cable/subscriptions/web_notifications_channel.js +// app/javascript/channels/web_notifications_channel.js // Client-side which assumes you've already requested // the right to send web notifications. import consumer from "./consumer" @@ -738,3 +768,8 @@ internally, irrespective of whether the application server is multi-threaded or Accordingly, Action Cable works with popular servers like Unicorn, Puma, and Passenger. + +## Testing + +You can find detailed instructions on how to test your Action Cable functionality in the +[testing guide](testing.html#testing-action-cable). diff --git a/guides/source/action_mailbox_basics.md b/guides/source/action_mailbox_basics.md index c5ec921ad5..c90892d456 100644 --- a/guides/source/action_mailbox_basics.md +++ b/guides/source/action_mailbox_basics.md @@ -21,7 +21,7 @@ Introduction Action Mailbox routes incoming emails to controller-like mailboxes for processing in Rails. It ships with ingresses for Amazon SES, Mailgun, Mandrill, Postmark, and SendGrid. You can also handle inbound mails directly via the -built-in Postfix ingress. +built-in Exim, Postfix, and Qmail ingresses. The inbound emails are turned into `InboundEmail` records using Active Record and feature lifecycle tracking, storage of the original email on cloud storage @@ -65,6 +65,36 @@ to deliver emails to your application via POST requests to `https://example.com`, you would specify the fully-qualified URL `https://example.com/rails/action_mailbox/amazon/inbound_emails`. +### Exim + +Tell Action Mailbox to accept emails from an SMTP relay: + +```ruby +# config/environments/production.rb +config.action_mailbox.ingress = :relay +``` + +Generate a strong password that Action Mailbox can use to authenticate requests to the relay ingress. + +Use `rails credentials:edit` to add the password to your application's encrypted credentials under +`action_mailbox.ingress_password`, where Action Mailbox will automatically find it: + +```yaml +action_mailbox: + ingress_password: ... +``` + +Alternatively, provide the password in the `RAILS_INBOUND_EMAIL_PASSWORD` environment variable. + +Configure Exim to pipe inbound emails to `bin/rails action_mailbox:ingress:exim`, +providing the `URL` of the relay ingress and the `INGRESS_PASSWORD` you +previously generated. If your application lived at `https://example.com`, the +full command would look like this: + +```shell +bin/rails action_mailbox:ingress:exim URL=https://example.com/rails/action_mailbox/relay/inbound_emails INGRESS_PASSWORD=... +``` + ### Mailgun Give Action Mailbox your @@ -126,14 +156,14 @@ the fully-qualified URL `https://example.com/rails/action_mailbox/mandrill/inbou ### Postfix -Tell Action Mailbox to accept emails from Postfix: +Tell Action Mailbox to accept emails from an SMTP relay: ```ruby # config/environments/production.rb -config.action_mailbox.ingress = :postfix +config.action_mailbox.ingress = :relay ``` -Generate a strong password that Action Mailbox can use to authenticate requests to the Postfix ingress. +Generate a strong password that Action Mailbox can use to authenticate requests to the relay ingress. Use `rails credentials:edit` to add the password to your application's encrypted credentials under `action_mailbox.ingress_password`, where Action Mailbox will automatically find it: @@ -151,8 +181,8 @@ the `URL` of the Postfix ingress and the `INGRESS_PASSWORD` you previously generated. If your application lived at `https://example.com`, the full command would look like this: -```bash -$ URL=https://example.com/rails/action_mailbox/postfix/inbound_emails INGRESS_PASSWORD=... rails action_mailbox:ingress:postfix +```shell +$ bin/rails action_mailbox:ingress:postfix URL=https://example.com/rails/action_mailbox/relay/inbound_emails INGRESS_PASSWORD=... ``` ### Postmark @@ -191,6 +221,36 @@ https://actionmailbox:PASSWORD@example.com/rails/action_mailbox/postmark/inbound NOTE: When configuring your Postmark inbound webhook, be sure to check the box labeled **"Include raw email content in JSON payload"**. Action Mailbox needs the raw email content to work. +### Qmail + +Tell Action Mailbox to accept emails from an SMTP relay: + +```ruby +# config/environments/production.rb +config.action_mailbox.ingress = :relay +``` + +Generate a strong password that Action Mailbox can use to authenticate requests to the relay ingress. + +Use `rails credentials:edit` to add the password to your application's encrypted credentials under +`action_mailbox.ingress_password`, where Action Mailbox will automatically find it: + +```yaml +action_mailbox: + ingress_password: ... +``` + +Alternatively, provide the password in the `RAILS_INBOUND_EMAIL_PASSWORD` environment variable. + +Configure Qmail to pipe inbound emails to `bin/rails action_mailbox:ingress:qmail`, +providing the `URL` of the relay ingress and the `INGRESS_PASSWORD` you +previously generated. If your application lived at `https://example.com`, the +full command would look like this: + +```shell +bin/rails action_mailbox:ingress:qmail URL=https://example.com/rails/action_mailbox/relay/inbound_emails INGRESS_PASSWORD=... +``` + ### SendGrid Tell Action Mailbox to accept emails from SendGrid: diff --git a/guides/source/testing.md b/guides/source/testing.md index f34f9d95f4..576c4d768c 100644 --- a/guides/source/testing.md +++ b/guides/source/testing.md @@ -33,11 +33,11 @@ Rails creates a `test` directory for you as soon as you create a Rails project u ```bash $ ls -F test -application_system_test_case.rb fixtures/ integration/ models/ test_helper.rb -controllers/ helpers/ mailers/ system/ +application_system_test_case.rb controllers/ helpers/ mailers/ system/ +channels/ fixtures/ integration/ models/ test_helper.rb ``` -The `helpers`, `mailers`, and `models` directories are meant to hold tests for view helpers, mailers, and models, respectively. The `controllers` directory is meant to hold tests for controllers, routes, and views. The `integration` directory is meant to hold tests for interactions between controllers. +The `helpers`, `mailers`, and `models` directories are meant to hold tests for view helpers, mailers, and models, respectively. The `channels` directory is meant to hold tests for Action Cable connection and channels. The `controllers` directory is meant to hold tests for controllers, routes, and views. The `integration` directory is meant to hold tests for interactions between controllers. The system test directory holds system tests, which are used for full browser testing of your application. System tests allow you to test your application @@ -1731,6 +1731,114 @@ class ProductTest < ActiveJob::TestCase end ``` +Testing Action Cable +-------------------- + +Since Action Cable is used at different levels inside your application, +you'll need to test both the channels and connection classes themsleves and that other +entities broadcast correct messages. + +### Connection Test Case + +By default, when you generate new Rails application with Action Cable, a test for the base connection class (`ApplicationCable::Connection`) is generated as well under `test/channels/application_cable` directory. + +Connection tests aim to check whether a connection's identifiers gets assigned properly +or that any improper connection requests are rejected. Here is an example: + +```ruby +class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase + test "connects with params" do + # Simulate a connection opening by calling the `connect` method + connect params: { user_id: 42 } + + # You can access the Connection object via `connection` in tests + assert_equal connection.user_id, "42" + end + + test "rejects connection without params" do + # Use `assert_reject_connection` matcher to verify that + # connection is rejected + assert_reject_connection { connect } + end +end +``` + +You can also specify request cookies the same way you do in integration tests: + + +```ruby +test "connects with_cookies" do + cookies.signed[:user_id] = "42" + + connect + + assert_equal connection.user_id, "42" +end +``` + +See the API documentation for [`AcionCable::Connection::TestCase`](http://api.rubyonrails.org/classes/ActionCable/Connection/TestCase.html) for more information. + + +### Channel Test Case + +By default, when you generate a channel, an associated test will be generated as well +under the `test/channels` directory. Here's an example test with a chat channel: + +```ruby +require "test_helper" + +class ChatChannelTest < ActionCable::Channel::TestCase + test "subscribes and stream for room" do + # Simulate a subscription creation by calling `subscribe` + subscribe room: "15" + + # You can access the Channel object via `subscription` in tests + assert subscription.confirmed? + assert_has_stream "chat_15" + end +end +``` + +This test is pretty simple and only asserts that the channel subscribes the connection to a particular stream. + +You can also specify the underlying connection identifiers. Here's an example test with a web notifications channel: + +```ruby +require "test_helper" + +class WebNotificationsChannelTest < ActionCable::Channel::TestCase + test "subscribes and stream for user" do + stub_connection current_user: users[:john] + + subscribe + + assert_has_stream_for users[:john] + end +end +``` + +See the API documentation for [`AcionCable::Channel::TestCase`](http://api.rubyonrails.org/classes/ActionCable/Channel/TestCase.html) for more information. + +### Custom Assertions And Testing Broadcasts Inside Other Components + +Action Cable ships with a bunch of custom assertions that can be used to lessen the verbosity of tests. For a full list of available assertions, see the API documentation for [`ActionCable::TestHelper`](http://api.rubyonrails.org/classes/ActionCable/TestHelper.html). + +It's a good practice to ensure that the correct message has been broadcasted inside another components (e.g. inside your controllers). This is precisely where +the custom assertions provided by Action Cable are pretty useful. For instance, +within a model: + +```ruby +require 'test_helper' + +class ProductTest < ActionCable::TestCase + test "broadcast status after charge" do + assert_broadcast_on("products:#{product.id}", type: "charged") do + product.charge(account) + end + end +end +``` + Additional Testing Resources ---------------------------- diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index 673b6eac86..aca55fae80 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -116,12 +116,20 @@ *Richard Schneeman* -* Support environment specific credentials file. +* Support environment specific credentials overrides. - For `production` environment look first for `config/credentials/production.yml.enc` file that can be decrypted by - `ENV["RAILS_MASTER_KEY"]` or `config/credentials/production.key` master key. - Edit given environment credentials file by command `rails credentials:edit --environment production`. - Default paths can be overwritten by setting `config.credentials.content_path` and `config.credentials.key_path`. + So any environment will look for `config/credentials/#{Rails.env}.yml.enc` and fall back + to `config/credentials.yml.enc`. + + The encryption key can be in `ENV["RAILS_MASTER_KEY"]` or `config/credentials/production.key`. + + Environment credentials overrides can be edited with `rails credentials:edit --environment production`. + If no override is setup for the passed environment, it will be created. + + Additionally, the default lookup paths can be overwritten with these configs: + + - `config.credentials.content_path` + - `config.credentials.key_path` *Wojciech Wnętrzak* diff --git a/railties/lib/rails/application/configuration.rb b/railties/lib/rails/application/configuration.rb index 3595f60bf8..c2403c57a7 100644 --- a/railties/lib/rails/application/configuration.rb +++ b/railties/lib/rails/application/configuration.rb @@ -293,25 +293,25 @@ module Rails end private - def credentials_available_for_current_env? - File.exist?("#{root}/config/credentials/#{Rails.env}.yml.enc") - end - def default_credentials_content_path if credentials_available_for_current_env? - File.join(root, "config", "credentials", "#{Rails.env}.yml.enc") + root.join("config", "credentials", "#{Rails.env}.yml.enc") else - File.join(root, "config", "credentials.yml.enc") + root.join("config", "credentials.yml.enc") end end def default_credentials_key_path if credentials_available_for_current_env? - File.join(root, "config", "credentials", "#{Rails.env}.key") + root.join("config", "credentials", "#{Rails.env}.key") else - File.join(root, "config", "master.key") + root.join("config", "master.key") end end + + def credentials_available_for_current_env? + File.exist?(root.join("config", "credentials", "#{Rails.env}.yml.enc")) + end end end end diff --git a/railties/lib/rails/commands/credentials/USAGE b/railties/lib/rails/commands/credentials/USAGE index 6b33d1ab74..d235592f46 100644 --- a/railties/lib/rails/commands/credentials/USAGE +++ b/railties/lib/rails/commands/credentials/USAGE @@ -41,9 +41,18 @@ from leaking. === Environment Specific Credentials -It is possible to have credentials for each environment. If the file for current environment exists it will take -precedence over `config/credentials.yml.enc`, thus for `production` environment first look for -`config/credentials/production.yml.enc` that can be decrypted using master key taken from `ENV["RAILS_MASTER_KEY"]` -or stored in `config/credentials/production.key`. -To edit given file use command `rails credentials:edit --environment production` -Default paths can be overwritten by setting `config.credentials.content_path` and `config.credentials.key_path`. +The `credentials` command supports passing an `--environment` option to create an +environment specific override. That override will takes precedence over the +global `config/credentials.yml.enc` file when running in that environment. So: + + rails credentials:edit --environment development + +will create `config/credentials/development.yml.enc` with the corresponding +encryption key in `config/credentials/development.key` if the credentials file +doesn't exist. + +The encryption key can also be put in `ENV["RAILS_MASTER_KEY"]`, which takes +precedence over the file encryption key. + +In addition to that, the default credentials lookup paths can be overriden through +`config.credentials.content_path` and `config.credentials.key_path`. diff --git a/railties/lib/rails/commands/credentials/credentials_command.rb b/railties/lib/rails/commands/credentials/credentials_command.rb index 4b30d208e0..852cd401d7 100644 --- a/railties/lib/rails/commands/credentials/credentials_command.rb +++ b/railties/lib/rails/commands/credentials/credentials_command.rb @@ -24,13 +24,11 @@ module Rails ensure_editor_available(command: "bin/rails credentials:edit") || (return) - encrypted = Rails.application.encrypted(content_path, key_path: key_path) - - ensure_encryption_key_has_been_added(key_path) if encrypted.key.nil? - ensure_encrypted_file_has_been_added(content_path, key_path) + ensure_encryption_key_has_been_added if credentials.key.nil? + ensure_credentials_have_been_added catch_editing_exceptions do - change_encrypted_file_in_system_editor(content_path, key_path) + change_credentials_in_system_editor end say "File encrypted and saved." @@ -41,36 +39,46 @@ module Rails def show require_application_and_environment! - encrypted = Rails.application.encrypted(content_path, key_path: key_path) - - say encrypted.read.presence || missing_encrypted_message(key: encrypted.key, key_path: key_path, file_path: content_path) + say credentials.read.presence || missing_credentials_message end private - def content_path - options[:environment] ? "config/credentials/#{options[:environment]}.yml.enc" : "config/credentials.yml.enc" - end - - def key_path - options[:environment] ? "config/credentials/#{options[:environment]}.key" : "config/master.key" + def credentials + Rails.application.encrypted(content_path, key_path: key_path) end - - def ensure_encryption_key_has_been_added(key_path) + def ensure_encryption_key_has_been_added encryption_key_file_generator.add_key_file(key_path) encryption_key_file_generator.ignore_key_file(key_path) end - def ensure_encrypted_file_has_been_added(file_path, key_path) - encrypted_file_generator.add_encrypted_file_silently(file_path, key_path) + def ensure_credentials_have_been_added + encrypted_file_generator.add_encrypted_file_silently(content_path, key_path) end - def change_encrypted_file_in_system_editor(file_path, key_path) - Rails.application.encrypted(file_path, key_path: key_path).change do |tmp_path| + def change_credentials_in_system_editor + credentials.change do |tmp_path| system("#{ENV["EDITOR"]} #{tmp_path}") end end + def missing_credentials_message + if credentials.key.nil? + "Missing '#{key_path}' to decrypt credentials. See `rails credentials:help`" + else + "File '#{content_path}' does not exist. Use `rails credentials:edit` to change that." + end + end + + + def content_path + options[:environment] ? "config/credentials/#{options[:environment]}.yml.enc" : "config/credentials.yml.enc" + end + + def key_path + options[:environment] ? "config/credentials/#{options[:environment]}.key" : "config/master.key" + end + def encryption_key_file_generator require "rails/generators" @@ -85,14 +93,6 @@ module Rails Rails::Generators::EncryptedFileGenerator.new end - - def missing_encrypted_message(key:, key_path:, file_path:) - if key.nil? - "Missing '#{key_path}' to decrypt credentials. See `rails credentials:help`" - else - "File '#{file_path}' does not exist. Use `rails credentials:edit` to change that." - end - end end end end diff --git a/railties/lib/rails/generators/rails/app/app_generator.rb b/railties/lib/rails/generators/rails/app/app_generator.rb index 33002790d4..e777590be8 100644 --- a/railties/lib/rails/generators/rails/app/app_generator.rb +++ b/railties/lib/rails/generators/rails/app/app_generator.rb @@ -213,6 +213,7 @@ module Rails empty_directory_with_keep_file "test/helpers" empty_directory_with_keep_file "test/integration" + template "test/channels/application_cable/connection_test.rb" template "test/test_helper.rb" end @@ -440,6 +441,7 @@ module Rails if options[:skip_action_cable] remove_dir "app/javascript/channels" remove_dir "app/channels" + remove_dir "test/channels" end end diff --git a/railties/lib/rails/generators/rails/app/templates/test/channels/application_cable/connection_test.rb.tt b/railties/lib/rails/generators/rails/app/templates/test/channels/application_cable/connection_test.rb.tt new file mode 100644 index 0000000000..cc8337fc6d --- /dev/null +++ b/railties/lib/rails/generators/rails/app/templates/test/channels/application_cable/connection_test.rb.tt @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "test_helper" + +class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase + # test "connects with cookies" do + # cookies.signed[:user_id] = 42 + # + # connect + # + # assert_equal connection.user_id, "42" + # end +end diff --git a/railties/test/application/credentials_test.rb b/railties/test/application/credentials_test.rb new file mode 100644 index 0000000000..2f6b109b50 --- /dev/null +++ b/railties/test/application/credentials_test.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "isolation/abstract_unit" +require "env_helpers" + +class Rails::CredentialsTest < ActiveSupport::TestCase + include ActiveSupport::Testing::Isolation, EnvHelpers + + setup :build_app + teardown :teardown_app + + test "reads credentials from environment specific path" do + write_credentials_override(:production) + + app("production") + + assert_equal "revealed", Rails.application.credentials.mystery + end + + test "reads credentials from customized path and key" do + write_credentials_override(:staging) + add_to_env_config("production", "config.credentials.content_path = config.root.join('config/credentials/staging.yml.enc')") + add_to_env_config("production", "config.credentials.key_path = config.root.join('config/credentials/staging.key')") + + app("production") + + assert_equal "revealed", Rails.application.credentials.mystery + end + + test "reads credentials using environment variable key" do + write_credentials_override(:production, with_key: false) + + switch_env("RAILS_MASTER_KEY", credentials_key) do + app("production") + + assert_equal "revealed", Rails.application.credentials.mystery + end + end + + private + def write_credentials_override(name, with_key: true) + Dir.chdir(app_path) do + Dir.mkdir "config/credentials" + File.write "config/credentials/#{name}.key", credentials_key if with_key + + # secret_key_base: secret + # mystery: revealed + File.write "config/credentials/#{name}.yml.enc", + "vgvKu4MBepIgZ5VHQMMPwnQNsLlWD9LKmJHu3UA/8yj6x+3fNhz3DwL9brX7UA==--qLdxHP6e34xeTAiI--nrcAsleXuo9NqiEuhntAhw==" + end + end + + def credentials_key + "2117e775dc2024d4f49ddf3aeb585919" + end +end diff --git a/railties/test/application/multiple_applications_test.rb b/railties/test/application/multiple_applications_test.rb index d6c81c1fe2..432344bccc 100644 --- a/railties/test/application/multiple_applications_test.rb +++ b/railties/test/application/multiple_applications_test.rb @@ -165,12 +165,12 @@ module ApplicationTests app.config.some_setting = "a_different_setting" assert_equal "a_different_setting", app.config.some_setting, "The configuration's some_setting should be set." - new_config = Rails::Application::Configuration.new("root_of_application") + new_config = Rails::Application::Configuration.new(Pathname.new("root_of_application")) new_config.some_setting = "some_setting_dude" app.config = new_config assert_equal "some_setting_dude", app.config.some_setting, "The configuration's some_setting should have changed." - assert_equal "root_of_application", app.config.root, "The root should have changed to the new config's root." + assert_equal "root_of_application", app.config.root.to_s, "The root should have changed to the new config's root." assert_equal new_config, app.config, "The application's config should have changed to the new config." end end diff --git a/railties/test/application/rake/routes_test.rb b/railties/test/application/rake/routes_test.rb index bbcda7c258..9879d1f047 100644 --- a/railties/test/application/rake/routes_test.rb +++ b/railties/test/application/rake/routes_test.rb @@ -22,8 +22,8 @@ module ApplicationTests cart GET /cart(.:format) cart#show rails_amazon_inbound_emails POST /rails/action_mailbox/amazon/inbound_emails(.:format) action_mailbox/ingresses/amazon/inbound_emails#create rails_mandrill_inbound_emails POST /rails/action_mailbox/mandrill/inbound_emails(.:format) action_mailbox/ingresses/mandrill/inbound_emails#create - rails_postfix_inbound_emails POST /rails/action_mailbox/postfix/inbound_emails(.:format) action_mailbox/ingresses/postfix/inbound_emails#create rails_postmark_inbound_emails POST /rails/action_mailbox/postmark/inbound_emails(.:format) action_mailbox/ingresses/postmark/inbound_emails#create + rails_relay_inbound_emails POST /rails/action_mailbox/relay/inbound_emails(.:format) action_mailbox/ingresses/relay/inbound_emails#create rails_sendgrid_inbound_emails POST /rails/action_mailbox/sendgrid/inbound_emails(.:format) action_mailbox/ingresses/sendgrid/inbound_emails#create rails_mailgun_inbound_emails POST /rails/action_mailbox/mailgun/inbound_emails/mime(.:format) action_mailbox/ingresses/mailgun/inbound_emails#create rails_conductor_inbound_emails GET /rails/conductor/action_mailbox/inbound_emails(.:format) rails/conductor/action_mailbox/inbound_emails#index diff --git a/railties/test/commands/routes_test.rb b/railties/test/commands/routes_test.rb index f77214ac2a..b4f927060e 100644 --- a/railties/test/commands/routes_test.rb +++ b/railties/test/commands/routes_test.rb @@ -26,7 +26,6 @@ class Rails::Command::RoutesTest < ActiveSupport::TestCase PUT /post(.:format) posts#update DELETE /post(.:format) posts#destroy POST /post(.:format) posts#create - rails_postfix_inbound_emails POST /rails/action_mailbox/postfix/inbound_emails(.:format) action_mailbox/ingresses/postfix/inbound_emails#create rails_postmark_inbound_emails POST /rails/action_mailbox/postmark/inbound_emails(.:format) action_mailbox/ingresses/postmark/inbound_emails#create OUTPUT @@ -65,8 +64,8 @@ class Rails::Command::RoutesTest < ActiveSupport::TestCase POST /cart(.:format) cart#create rails_amazon_inbound_emails POST /rails/action_mailbox/amazon/inbound_emails(.:format) action_mailbox/ingresses/amazon/inbound_emails#create rails_mandrill_inbound_emails POST /rails/action_mailbox/mandrill/inbound_emails(.:format) action_mailbox/ingresses/mandrill/inbound_emails#create - rails_postfix_inbound_emails POST /rails/action_mailbox/postfix/inbound_emails(.:format) action_mailbox/ingresses/postfix/inbound_emails#create rails_postmark_inbound_emails POST /rails/action_mailbox/postmark/inbound_emails(.:format) action_mailbox/ingresses/postmark/inbound_emails#create + rails_relay_inbound_emails POST /rails/action_mailbox/relay/inbound_emails(.:format) action_mailbox/ingresses/relay/inbound_emails#create rails_sendgrid_inbound_emails POST /rails/action_mailbox/sendgrid/inbound_emails(.:format) action_mailbox/ingresses/sendgrid/inbound_emails#create rails_mailgun_inbound_emails POST /rails/action_mailbox/mailgun/inbound_emails/mime(.:format) action_mailbox/ingresses/mailgun/inbound_emails#create POST /rails/conductor/action_mailbox/inbound_emails(.:format) rails/conductor/action_mailbox/inbound_emails#create @@ -141,7 +140,6 @@ class Rails::Command::RoutesTest < ActiveSupport::TestCase PUT /admin/post(.:format) admin/posts#update DELETE /admin/post(.:format) admin/posts#destroy POST /admin/post(.:format) admin/posts#create - rails_postfix_inbound_emails POST /rails/action_mailbox/postfix/inbound_emails(.:format) action_mailbox/ingresses/postfix/inbound_emails#create rails_postmark_inbound_emails POST /rails/action_mailbox/postmark/inbound_emails(.:format) action_mailbox/ingresses/postmark/inbound_emails#create OUTPUT @@ -170,8 +168,8 @@ class Rails::Command::RoutesTest < ActiveSupport::TestCase Prefix Verb URI Pattern Controller#Action rails_amazon_inbound_emails POST /rails/action_mailbox/amazon/inbound_emails(.:format) action_mailbox/ingresses/amazon/inbound_emails#create rails_mandrill_inbound_emails POST /rails/action_mailbox/mandrill/inbound_emails(.:format) action_mailbox/ingresses/mandrill/inbound_emails#create - rails_postfix_inbound_emails POST /rails/action_mailbox/postfix/inbound_emails(.:format) action_mailbox/ingresses/postfix/inbound_emails#create rails_postmark_inbound_emails POST /rails/action_mailbox/postmark/inbound_emails(.:format) action_mailbox/ingresses/postmark/inbound_emails#create + rails_relay_inbound_emails POST /rails/action_mailbox/relay/inbound_emails(.:format) action_mailbox/ingresses/relay/inbound_emails#create rails_sendgrid_inbound_emails POST /rails/action_mailbox/sendgrid/inbound_emails(.:format) action_mailbox/ingresses/sendgrid/inbound_emails#create rails_mailgun_inbound_emails POST /rails/action_mailbox/mailgun/inbound_emails/mime(.:format) action_mailbox/ingresses/mailgun/inbound_emails#create rails_conductor_inbound_emails GET /rails/conductor/action_mailbox/inbound_emails(.:format) rails/conductor/action_mailbox/inbound_emails#index @@ -219,15 +217,15 @@ class Rails::Command::RoutesTest < ActiveSupport::TestCase URI | /rails/action_mailbox/mandrill/inbound_emails(.:format) Controller#Action | action_mailbox/ingresses/mandrill/inbound_emails#create --[ Route 4 ]-------------- - Prefix | rails_postfix_inbound_emails - Verb | POST - URI | /rails/action_mailbox/postfix/inbound_emails(.:format) - Controller#Action | action_mailbox/ingresses/postfix/inbound_emails#create - --[ Route 5 ]-------------- Prefix | rails_postmark_inbound_emails Verb | POST URI | /rails/action_mailbox/postmark/inbound_emails(.:format) Controller#Action | action_mailbox/ingresses/postmark/inbound_emails#create + --[ Route 5 ]-------------- + Prefix | rails_relay_inbound_emails + Verb | POST + URI | /rails/action_mailbox/relay/inbound_emails(.:format) + Controller#Action | action_mailbox/ingresses/relay/inbound_emails#create --[ Route 6 ]-------------- Prefix | rails_sendgrid_inbound_emails Verb | POST diff --git a/railties/test/credentials_test.rb b/railties/test/credentials_test.rb deleted file mode 100644 index 11765b0de5..0000000000 --- a/railties/test/credentials_test.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -require "isolation/abstract_unit" -require "env_helpers" - -class Rails::CredentialsTest < ActiveSupport::TestCase - include ActiveSupport::Testing::Isolation, EnvHelpers - - setup :build_app - teardown :teardown_app - - test "reads credentials from environment specific path" do - with_credentials do |content, key| - Dir.chdir(app_path) do - Dir.mkdir("config/credentials") - File.write("config/credentials/production.yml.enc", content) - File.write("config/credentials/production.key", key) - end - - app("production") - - assert_equal "revealed", Rails.application.credentials.mystery - end - end - - test "reads credentials from customized path and key" do - with_credentials do |content, key| - Dir.chdir(app_path) do - Dir.mkdir("config/credentials") - File.write("config/credentials/staging.yml.enc", content) - File.write("config/credentials/staging.key", key) - end - - add_to_env_config("production", "config.credentials.content_path = config.root.join('config/credentials/staging.yml.enc')") - add_to_env_config("production", "config.credentials.key_path = config.root.join('config/credentials/staging.key')") - app("production") - - assert_equal "revealed", Rails.application.credentials.mystery - end - end - - test "reads credentials using environment variable key" do - with_credentials do |content, key| - Dir.chdir(app_path) do - Dir.mkdir("config/credentials") - File.write("config/credentials/production.yml.enc", content) - end - - switch_env("RAILS_MASTER_KEY", key) do - app("production") - - assert_equal "revealed", Rails.application.credentials.mystery - end - end - end - - private - def with_credentials - key = "2117e775dc2024d4f49ddf3aeb585919" - # secret_key_base: secret - # mystery: revealed - content = "vgvKu4MBepIgZ5VHQMMPwnQNsLlWD9LKmJHu3UA/8yj6x+3fNhz3DwL9brX7UA==--qLdxHP6e34xeTAiI--nrcAsleXuo9NqiEuhntAhw==" - yield(content, key) - end -end diff --git a/railties/test/generators/app_generator_test.rb b/railties/test/generators/app_generator_test.rb index 839e6feb39..e0cd7f90ce 100644 --- a/railties/test/generators/app_generator_test.rb +++ b/railties/test/generators/app_generator_test.rb @@ -81,6 +81,7 @@ DEFAULT_APP_FILES = %w( test/test_helper.rb test/fixtures test/fixtures/files + test/channels/application_cable/connection_test.rb test/controllers test/models test/helpers @@ -363,6 +364,8 @@ class AppGeneratorTest < Rails::Generators::TestCase assert_file "#{app_root}/config/environments/production.rb" do |content| assert_no_match(/config\.action_cable/, content) end + + assert_no_file "#{app_root}/test/channels/application_cable/connection_test.rb" end def test_app_update_does_not_generate_bootsnap_contents_when_skip_bootsnap_is_given diff --git a/railties/test/generators/channel_generator_test.rb b/railties/test/generators/channel_generator_test.rb index 1cb8465539..1a25422c3c 100644 --- a/railties/test/generators/channel_generator_test.rb +++ b/railties/test/generators/channel_generator_test.rb @@ -67,12 +67,23 @@ class ChannelGeneratorTest < Rails::Generators::TestCase assert_file "app/javascript/channels/consumer.js" end + def test_invokes_default_test_framework + run_generator %w(chat -t=test_unit) + + assert_file "test/channels/chat_channel_test.rb" do |test| + assert_match(/class ChatChannelTest < ActionCable::Channel::TestCase/, test) + assert_match(/# test "subscribes" do/, test) + assert_match(/# assert subscription.confirmed\?/, test) + end + end + def test_channel_on_revoke run_generator ["chat"] run_generator ["chat"], behavior: :revoke assert_no_file "app/channels/chat_channel.rb" assert_no_file "app/javascript/channels/chat_channel.js" + assert_no_file "test/channels/chat_channel_test.rb" assert_file "app/channels/application_cable/channel.rb" assert_file "app/channels/application_cable/connection.rb" @@ -88,5 +99,8 @@ class ChannelGeneratorTest < Rails::Generators::TestCase assert_no_file "app/javascript/channels/chat_channel_channel.js" assert_file "app/javascript/channels/chat_channel.js" + + assert_no_file "test/channels/chat_channel_channel_test.rb" + assert_file "test/channels/chat_channel_test.rb" end end |