Add minimal authentication flow to get started
This commit is contained in:
@@ -1,4 +1,20 @@
|
||||
module ApplicationCable
|
||||
class Connection < ActionCable::Connection::Base
|
||||
include Authentication::SessionLookup
|
||||
|
||||
identified_by :current_user
|
||||
|
||||
def connect
|
||||
self.current_user = find_verified_user
|
||||
end
|
||||
|
||||
private
|
||||
def find_verified_user
|
||||
if verified_session = find_session_by_cookie
|
||||
verified_session.user
|
||||
else
|
||||
reject_unauthorized_connection
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include Authentication
|
||||
|
||||
allow_browser versions: :modern
|
||||
end
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
module Authentication
|
||||
extend ActiveSupport::Concern
|
||||
include SessionLookup
|
||||
|
||||
included do
|
||||
before_action :require_authentication
|
||||
helper_method :signed_in?
|
||||
|
||||
protect_from_forgery with: :exception, unless: -> { authenticated_by.bot_key? }
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def require_unauthenticated_access(**options)
|
||||
allow_unauthenticated_access **options
|
||||
before_action :redirect_signed_in_user_to_root, **options
|
||||
end
|
||||
|
||||
def allow_unauthenticated_access(**options)
|
||||
skip_before_action :require_authentication, **options
|
||||
before_action :restore_authentication, **options
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def signed_in?
|
||||
Current.user.present?
|
||||
end
|
||||
|
||||
def require_authentication
|
||||
restore_authentication || request_authentication
|
||||
end
|
||||
|
||||
def restore_authentication
|
||||
if session = find_session_by_cookie
|
||||
resume_session session
|
||||
end
|
||||
end
|
||||
|
||||
def request_authentication
|
||||
session[:return_to_after_authenticating] = request.url
|
||||
redirect_to new_session_url
|
||||
end
|
||||
|
||||
def redirect_signed_in_user_to_root
|
||||
redirect_to root_url if signed_in?
|
||||
end
|
||||
|
||||
def start_new_session_for(user)
|
||||
user.sessions.start!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
|
||||
authenticated_as session
|
||||
end
|
||||
end
|
||||
|
||||
def resume_session(session)
|
||||
session.resume user_agent: request.user_agent, ip_address: request.remote_ip
|
||||
authenticated_as session
|
||||
end
|
||||
|
||||
def authenticated_as(session)
|
||||
Current.user = session.user
|
||||
set_authenticated_by(:session)
|
||||
cookies.signed.permanent[:session_token] = { value: session.token, httponly: true, same_site: :lax }
|
||||
end
|
||||
|
||||
def post_authenticating_url
|
||||
session.delete(:return_to_after_authenticating) || root_url
|
||||
end
|
||||
|
||||
def reset_authentication
|
||||
cookies.delete(:session_token)
|
||||
end
|
||||
|
||||
def set_authenticated_by(method)
|
||||
@authenticated_by = method.to_s.inquiry
|
||||
end
|
||||
|
||||
def authenticated_by
|
||||
@authenticated_by ||= "".inquiry
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
module Authentication::SessionLookup
|
||||
def find_session_by_cookie
|
||||
if token = cookies.signed[:session_token]
|
||||
Session.find_by(token: token)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
class SessionsController < ApplicationController
|
||||
allow_unauthenticated_access only: %i[ new create ]
|
||||
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { render_rejection :too_many_requests }
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
if user = User.active.authenticate_by(user_params)
|
||||
start_new_session_for user
|
||||
redirect_to post_authenticating_url
|
||||
else
|
||||
render_rejection :unauthorized
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
reset_authentication
|
||||
|
||||
redirect_to root_url
|
||||
end
|
||||
|
||||
private
|
||||
def user_params
|
||||
params.require(:user).permit(:email_address, :password)
|
||||
end
|
||||
|
||||
def render_rejection(status)
|
||||
flash[:alert] = "Too many requests or unauthorized."
|
||||
render :new, status: status
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,2 @@
|
||||
module SessionsHelper
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class Current < ActiveSupport::CurrentAttributes
|
||||
attribute :user
|
||||
|
||||
def organization
|
||||
user.organization
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
class Organization < ApplicationRecord
|
||||
has_many :users
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
class Session < ApplicationRecord
|
||||
ACTIVITY_REFRESH_RATE = 1.hour
|
||||
|
||||
has_secure_token
|
||||
|
||||
belongs_to :user
|
||||
|
||||
before_create { self.last_active_at ||= Time.now }
|
||||
|
||||
def self.start!(user_agent:, ip_address:)
|
||||
create! user_agent: user_agent, ip_address: ip_address
|
||||
end
|
||||
|
||||
def resume(user_agent:, ip_address:)
|
||||
if last_active_at.before?(ACTIVITY_REFRESH_RATE.ago)
|
||||
update! user_agent: user_agent, ip_address: ip_address, last_active_at: Time.now
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
class User < ApplicationRecord
|
||||
belongs_to :organization
|
||||
has_many :sessions, dependent: :destroy
|
||||
|
||||
has_secure_password validations: false
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
|
||||
def current?
|
||||
self == Current.user
|
||||
end
|
||||
|
||||
def deactivate
|
||||
transaction do
|
||||
sessions.delete_all
|
||||
update! active: false, email_address: deactived_email_address
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def deactived_email_address
|
||||
email_address&.gsub(/@/, "-deactivated-#{SecureRandom.uuid}@")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
<%= form_with model: User.new, url: session_path do |f| %>
|
||||
<%= f.label :email_address %>
|
||||
<%= f.email_field :email_address %>
|
||||
|
||||
<%= f.label :password %>
|
||||
<%= f.password_field :password %>
|
||||
|
||||
<%= f.submit "Sign in" %>
|
||||
<% end %>
|
||||
@@ -0,0 +1,3 @@
|
||||
<h1>Hello <%= Current.user.name %></h1>
|
||||
|
||||
<%= button_to "Sign out", session_path, method: :delete %>
|
||||
@@ -61,4 +61,7 @@ Rails.application.configure do
|
||||
|
||||
# Raise error when a before_action's only/except options reference missing actions
|
||||
config.action_controller.raise_on_missing_callback_actions = true
|
||||
|
||||
# Load test helpers
|
||||
config.autoload_paths += %w[ test/test_helpers ]
|
||||
end
|
||||
|
||||
+3
-6
@@ -1,10 +1,7 @@
|
||||
Rails.application.routes.draw do
|
||||
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
||||
root "sessions#show"
|
||||
|
||||
resource :session
|
||||
|
||||
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
|
||||
# Can be used by load balancers and uptime monitors to verify that the app is live.
|
||||
get "up" => "rails/health#show", as: :rails_health_check
|
||||
|
||||
# Defines the root path route ("/")
|
||||
# root "posts#index"
|
||||
end
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
class CreateOrganizations < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :organizations do |t|
|
||||
t.string :name, null: false, index: { unique: true }
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
class CreateUsers < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :users do |t|
|
||||
t.references :organization, null: false, foreign_key: true
|
||||
|
||||
t.string :name, null: false
|
||||
t.string :email_address, null: false, index: { unique: true }
|
||||
t.string :password_digest, null: false
|
||||
t.boolean :active, null: false, default: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
class CreateSessions < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :sessions do |t|
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.string :token, null: false, index: { unique: true }
|
||||
t.string :ip_address
|
||||
t.string :user_agent
|
||||
t.datetime :last_active_at, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
Generated
+47
@@ -0,0 +1,47 @@
|
||||
# This file is auto-generated from the current state of the database. Instead
|
||||
# of editing this file, please use the migrations feature of Active Record to
|
||||
# incrementally modify your database, and then regenerate this schema definition.
|
||||
#
|
||||
# This file is the source Rails uses to define your schema when running `bin/rails
|
||||
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
|
||||
# be faster and is potentially less error prone than running all of your
|
||||
# migrations from scratch. Old migrations may fail to apply correctly if those
|
||||
# migrations use external dependencies or application code.
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2024_06_21_150944) do
|
||||
create_table "organizations", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["name"], name: "index_organizations_on_name", unique: true
|
||||
end
|
||||
|
||||
create_table "sessions", force: :cascade do |t|
|
||||
t.integer "user_id", null: false
|
||||
t.string "token", null: false
|
||||
t.string "ip_address"
|
||||
t.string "user_agent"
|
||||
t.datetime "last_active_at", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["token"], name: "index_sessions_on_token", unique: true
|
||||
t.index ["user_id"], name: "index_sessions_on_user_id"
|
||||
end
|
||||
|
||||
create_table "users", force: :cascade do |t|
|
||||
t.integer "organization_id", null: false
|
||||
t.string "name", null: false
|
||||
t.string "email_address", null: false
|
||||
t.string "password_digest", null: false
|
||||
t.boolean "active", default: true, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["email_address"], name: "index_users_on_email_address", unique: true
|
||||
t.index ["organization_id"], name: "index_users_on_organization_id"
|
||||
end
|
||||
|
||||
add_foreign_key "sessions", "users"
|
||||
add_foreign_key "users", "organizations"
|
||||
end
|
||||
@@ -0,0 +1,156 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Your browser is not supported (406)</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
--lch-black: 0% 0 0;
|
||||
--lch-gray: 75% 0.005 96;
|
||||
--lch-white: 100% 0 0;
|
||||
|
||||
--color-border: oklch(var(--lch-gray));
|
||||
--color-bg: oklch(var(--lch-white));
|
||||
--color-text: oklch(var(--lch-black));
|
||||
|
||||
--btn-size: 2.65em;
|
||||
|
||||
background-color: var(--color-bg);
|
||||
block-size: 100dvh;
|
||||
color: var(--color-text);
|
||||
display: grid;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
--lch-black: 100% 0 0;
|
||||
--lch-gray: 44.95% 0 0;
|
||||
--lch-white: 0% 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: var(--color-text);
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
fill: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2em;
|
||||
justify-content: start;
|
||||
margin-block: 2dvh 15dvh;
|
||||
}
|
||||
|
||||
.error__img {
|
||||
aspect-ratio: 1;
|
||||
background-color: var(--color-text);
|
||||
block-size: auto;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
inline-size: 30dvh;
|
||||
|
||||
svg {
|
||||
fill: var(--color-bg);
|
||||
grid-area: 1/1;
|
||||
max-inline-size: 66%;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
fill: var(--color-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
max-inline-size: 50ch;
|
||||
padding: 0 3dvw;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1em;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
align-items: center;
|
||||
aspect-ratio: 1;
|
||||
background-color: var(--color-bg);
|
||||
block-size: var(--btn-size);
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
font-weight: 600;
|
||||
font-size: 1.4rem;
|
||||
gap: 0.5em;
|
||||
inline-size: var(--btn-size);
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
|
||||
svg {
|
||||
-webkit-touch-callout: none;
|
||||
grid-area: 1/1;
|
||||
inline-size: 1.3em;
|
||||
max-inline-size: unset;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.for-screen-reader {
|
||||
clip-path: inset(50%);
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="error">
|
||||
<div class="error__img">
|
||||
<svg enable-background="new 0 0 24 24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-label="Your browser is not supported">
|
||||
<path d="m17.2 14.4c.1-.8.2-1.6.2-2.4s-.1-1.6-.2-2.4h4.1c.2.8.3 1.6.3 2.4s-.1 1.6-.3 2.4m-6.2 6.7c.7-1.3 1.3-2.8 1.7-4.3h3.5c-1.1 2-3 3.5-5.2 4.3m-.3-6.7h-5.6c-.1-.8-.2-1.6-.2-2.4s.1-1.6.2-2.4h5.6c.1.8.2 1.6.2 2.4s-.1 1.6-.2 2.4m-2.8 7.2c-1-1.4-1.8-3-2.3-4.8h4.6c-.5 1.7-1.3 3.3-2.3 4.8m-4.8-14.4h-3.5c1.1-2 3-3.5 5.2-4.3-.7 1.4-1.3 2.8-1.7 4.3m-3.5 9.6h3.5c.4 1.5 1 2.9 1.7 4.3-2.2-.8-4.1-2.3-5.2-4.3m-1-2.4c-.2-.8-.3-1.6-.3-2.4s.1-1.6.3-2.4h4.1c-.1.8-.2 1.6-.2 2.4s.1 1.6.2 2.4m5.2-12c1 1.4 1.8 3 2.3 4.8h-4.6c.5-1.7 1.3-3.3 2.3-4.8m8.3 4.8h-3.5c-.4-1.5-.9-2.9-1.7-4.3 2.2.8 4.1 2.3 5.2 4.3m-8.3-7.2c-6.6 0-12 5.4-12 12s5.4 12 12 12 12-5.4 12-12-5.4-12-12-12z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<a href="/" class="btn" autofocus="true">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="m21.864 9.5h-11.607a.25.25 0 0 1 -.174-.43l3.864-3.721a2.609 2.609 0 0 0 -.075-3.682 2.612 2.612 0 0 0 -3.68-.077l-9.792 9.699a1 1 0 0 0 -.008 1.411l9.625 9.724a2.66 2.66 0 0 0 3.755-3.757l-3.729-3.733a.25.25 0 0 1 .177-.427h11.673c1.556 0 2-1.675 2-2.51a2.28 2.28 0 0 0 -2.029-2.497z" />
|
||||
</svg>
|
||||
<span class="for-screen-reader">Go back</span>
|
||||
</a>
|
||||
<div>
|
||||
<p>🇺🇸 Upgrade to a supported web browser. Splat requires a modern web browser. Please use one of the browsers listed below and make sure auto-updates are enabled.</p>
|
||||
<p>🇪🇸 Actualiza a un navegador web compatible. Splat requiere un navegador web moderno. Utiliza uno de los navegadores listados a continuación y asegúrate de que las actualizaciones automáticas estén habilitadas.</p>
|
||||
<p>🇫🇷 Mettez à jour vers un navigateur web pris en charge. Splat nécessite un navigateur web moderne. Veuillez utiliser l'un des navigateurs répertoriés ci-dessous et assurez-vous que les mises à jour automatiques sont activées.</p>
|
||||
<p>🇮🇳 समर्थित वेब ब्राउज़र में अपग्रेड करें। Splat को एक आधुनिक वेब ब्राउज़र की आवश्यकता है। कृपया नीचे सूचीबद्ध ब्राउज़रों में से कोई एक का उपयोग करें और सुनिश्चित करें कि स्वचालित अपडेट्स सक्षम हैं।</p>
|
||||
<p>🇩🇪 Aktualisieren Sie auf einen unterstützten Webbrowser. Splat erfordert einen modernen Webbrowser. Verwenden Sie bitte einen der unten aufgeführten Browser und stellen Sie sicher, dass automatische Updates aktiviert sind.</p>
|
||||
<p>🇧🇷 Atualize para um navegador compatível. O Splat requer um navegador moderno. Por favor, use um dos navegadores listados abaixo e certifique-se de que as atualizações automáticas estão ativadas.</p>
|
||||
<p><strong>Safari 17.2+, Chrome 119+, Firefox 121+, Opera 104+</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
require "test_helper"
|
||||
|
||||
class SessionsControllerTest < ActionDispatch::IntegrationTest
|
||||
ALLOWED_BROWSER = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15"
|
||||
DISALLOWED_BROWSER = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/114.0"
|
||||
|
||||
test "new" do
|
||||
get new_session_url
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "new denied with incompatible browser" do
|
||||
get new_session_url, env: { "HTTP_USER_AGENT" => DISALLOWED_BROWSER }
|
||||
assert_select "svg", message: /Your browser is not supported/
|
||||
end
|
||||
|
||||
test "new allowed with compatible browser" do
|
||||
get new_session_url, env: { "HTTP_USER_AGENT" => ALLOWED_BROWSER }
|
||||
assert_select "svg", message: /Your browser is not supported/, count: 0
|
||||
end
|
||||
|
||||
test "create with valid credentials" do
|
||||
post session_url, params: { user: { email_address: "david@37signals.com", password: "secret123456" } }
|
||||
|
||||
assert_redirected_to root_url
|
||||
assert parsed_cookies.signed[:session_token]
|
||||
end
|
||||
|
||||
test "create with invalid credentials" do
|
||||
post session_url, params: { user: { email_address: "david@37signals.com", password: "wrong" } }
|
||||
|
||||
assert_response :unauthorized
|
||||
assert_nil parsed_cookies.signed[:session_token]
|
||||
end
|
||||
|
||||
test "destroy" do
|
||||
sign_in :kevin
|
||||
|
||||
delete session_url
|
||||
|
||||
assert_redirected_to root_url
|
||||
assert_not cookies[:session_token].present?
|
||||
end
|
||||
end
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
37signals:
|
||||
name: 37signals
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
<% digest = BCrypt::Password.create("secret123456") %>
|
||||
|
||||
david:
|
||||
organization: 37signals
|
||||
name: David
|
||||
email_address: david@37signals.com
|
||||
password_digest: <%= digest %>
|
||||
|
||||
jz:
|
||||
organization: 37signals
|
||||
name: JZ
|
||||
email_address: jz@37signals.com
|
||||
password_digest: <%= digest %>
|
||||
|
||||
kevin:
|
||||
organization: 37signals
|
||||
name: Kevin
|
||||
email_address: kevin@37signals.com
|
||||
password_digest: <%= digest %>
|
||||
@@ -0,0 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class OrganizationTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class UserTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
+1
-1
@@ -10,6 +10,6 @@ module ActiveSupport
|
||||
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
|
||||
fixtures :all
|
||||
|
||||
# Add more helper methods to be used by all tests here...
|
||||
include SessionTestHelper
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
module SessionTestHelper
|
||||
def parsed_cookies
|
||||
ActionDispatch::Cookies::CookieJar.build(request, cookies.to_hash)
|
||||
end
|
||||
|
||||
def sign_in(user)
|
||||
user = users(user) unless user.is_a? User
|
||||
post session_url, params: { user: { email_address: user.email_address, password: "secret123456" } }
|
||||
assert cookies[:session_token].present?
|
||||
end
|
||||
|
||||
def sign_out
|
||||
delete session_url
|
||||
assert_not cookies[:session_token].present?
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user