+ Keys are nemo_live_ plus 20 base58 characters, minted on
+ <%= link_to "your settings page", you_api_path(tab: "tokens") %> and stored as a SHA-256
+ digest. Shown once. Never accepted as a query parameter.
+
+
GET /api/v1/token// what this key is
+
+{ "name": "Toolbox", "prefix": "nemo_live_7Fj2",
+ "owner_user_id": "U0A5PLKMB25", "rate_per_minute": <%= @rate %> }
+
+
+
+
GET Check a member
+
/api/v1/channels/{channel_id}/managers/{user_id}
+
One channel and one member per request. There is no batch form.
+
+
+
+
param
pattern
notes
+
+
+
+
channel_id
+
<%= channel_pattern %>
+
Public or private. Never validated against an index.
When the channel was last read from Slack. Absent when withheld.
+
stale
bool?
+
true past the cache TTL. Answer still served.
+
opt_in_url
string?
+
Present only when withheld.
+
+
+
+
+
+
+
Consent states
+
Branch on consent, not on the status code. Withheld is a 200.
+
+
+
+
consent
is_manager
meaning
+
+
+
granted
true | false
+
Member opted in. This is the answer.
+
withheld
null
+
Member has not opted in, or is unknown to us.
+
+
+
+
+ Consent is settled before the channel is touched, so a withheld body is identical whatever
+ channel you named: same fields, no synced_at, no stale. That is
+ what lets private channels be answered at all. Mnemosyne does not message members on a
+ caller's behalf; surface opt_in_url yourself.
+
+
+ A channel with no managers on record, or one that does not exist, answers
+ is_manager: false. The API never confirms whether a channel exists.
+
+
+
+
+
Rate limits
+
+ <%= @rate %> requests per fixed 60 second window, per key. Overridable per key
+ by a community manager.
+
+ Opted in to
+ ">
+ <%= granted.zero? ? "nothing" : "#{granted} of #{Api::Capability::KEYS.size}" %>
+
+
+
+ Live tokens
+ "><%= live.size %> of <%= cap %>
+
+
+ Channels you are in
+ ">
+ <%= @rooms.any? ? number_with_delimiter(@rooms.size) : "n/a" %>
+
+
+
+
+
+
+
+<% if @tab == "tokens" && !api_off %>
+ <%= render "you/api/mint" %>
+ <% live.each do |token| %>
+ <%= render "you/api/kill", token: token %>
+ <% end %>
+<% end %>
diff --git a/web/config/routes.rb b/web/config/routes.rb
index 58886d15..f9f2205b 100644
--- a/web/config/routes.rb
+++ b/web/config/routes.rb
@@ -10,6 +10,22 @@
get "dev/be/:user_id", to: "dev_sessions#create", as: :dev_be
end
+ namespace :api do
+ namespace :v1 do
+ resource :token, only: [:show], controller: "tokens"
+ get "channels/:channel_id/managers/:user_id", to: "channel_managers#show",
+ as: :channel_manager
+ end
+ end
+
+ get "docs", to: "docs#show", as: :docs
+
+ namespace :you do
+ get "api", to: "api#show", as: :api
+ resource :consent, only: [:update], controller: "consents"
+ resources :tokens, only: [:create, :destroy]
+ end
+
namespace :fd do
root to: "fire#show"
post "cases/merge", to: "merges#create", as: :merge_cases
@@ -21,6 +37,9 @@
end
resource :search, only: [:show], controller: "searches"
resource :settings, only: [:show]
+ patch "api_setting", to: "api_settings#update", as: :api_setting
+ patch "api_tokens/:id/rate", to: "api_settings#rate", as: :api_token_rate
+ delete "api_tokens/:id", to: "api_settings#destroy", as: :api_token
get "audit", to: "audits#show", as: :audit
get "slack_account/callback", to: "slack_accounts#callback", as: :slack_account_callback
resource :slack_account, only: [:create, :destroy], controller: "slack_accounts"
diff --git a/web/test/controllers/home_controller_test.rb b/web/test/controllers/home_controller_test.rb
index 809b7bf1..5b4132db 100644
--- a/web/test/controllers/home_controller_test.rb
+++ b/web/test/controllers/home_controller_test.rb
@@ -22,12 +22,12 @@ class HomeControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to login_path
end
- test "a staff row with no roles cannot sign in, so it never reaches the dashboard" do
+ test "a staff row with no roles signs in, and still never reaches the dashboard" do
staff = Staff.create!(user_id: "UTESTNONE1")
sign_in_as(staff)
- assert_redirected_to auth_failure_path(message: "not_allowlisted")
+ assert_redirected_to you_api_path
get root_path
- assert_redirected_to login_path
+ assert_redirected_to auth_failure_path(message: "not_allowlisted")
end
end
diff --git a/web/test/controllers/sessions_controller_test.rb b/web/test/controllers/sessions_controller_test.rb
index 353e3113..d17689e8 100644
--- a/web/test/controllers/sessions_controller_test.rb
+++ b/web/test/controllers/sessions_controller_test.rb
@@ -20,23 +20,23 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
assert_equal "UTESTALLOWED", session[:user_id]
end
- test "a staff row with no grant is rejected like a stranger" do
+ test "a staff row with no grant gets the member area, not the dashboard" do
Staff.create!(user_id: "UTESTNOGRANT", community_manager: false)
mock_hca_auth("UTESTNOGRANT")
get "/auth/hackclub/callback"
- assert_redirected_to auth_failure_path(message: "not_allowlisted")
- assert_nil session[:user_id]
+ assert_redirected_to you_api_path
+ assert_equal "UTESTNOGRANT", session[:user_id]
end
- test "unknown slack id is rejected" do
+ test "unknown slack id gets the member area too" do
mock_hca_auth("UNOTALLOWED")
get "/auth/hackclub/callback"
- assert_redirected_to auth_failure_path(message: "not_allowlisted")
- assert_nil session[:user_id]
+ assert_redirected_to you_api_path
+ assert_equal "UNOTALLOWED", session[:user_id]
end
test "missing slack_id claim is rejected" do
@@ -49,7 +49,8 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
get "/auth/hackclub/callback"
- assert_redirected_to auth_failure_path(message: "not_allowlisted")
+ assert_redirected_to auth_failure_path(message: "no_slack_id")
+ assert_nil session[:user_id]
end
test "logout clears the session" do
diff --git a/web/test/integration/api_auth_test.rb b/web/test/integration/api_auth_test.rb
new file mode 100644
index 00000000..285fb03e
--- /dev/null
+++ b/web/test/integration/api_auth_test.rb
@@ -0,0 +1,97 @@
+require "test_helper"
+
+class ApiAuthTest < ActionDispatch::IntegrationTest
+ setup do
+ Fd::Flag.set!(:public_api, true, by: "UBOSS")
+ @token, @secret = Api::Token.mint!("UOWNER1", "Toolbox")
+ end
+
+ teardown do
+ Fd::Flag.delete_all
+ Current.forget_flags
+ end
+
+ def ask(key = @secret, path: api_v1_token_path)
+ get path, headers: key ? { "Authorization" => "Bearer #{key}" } : {}
+ end
+
+ def body
+ JSON.parse(response.body)
+ end
+
+ test "a live key is told who it is, and never what it is" do
+ ask
+
+ assert_response :success
+ assert_equal ["Toolbox", "UOWNER1", 20], body.values_at("name", "owner_user_id",
+ "rate_per_minute")
+ assert_equal @token.prefix, body["prefix"]
+ assert_no_match(/#{@secret}/, response.body, "the key itself must never come back")
+ end
+
+ test "no header at all is an invalid token, not a crash" do
+ ask(nil)
+
+ assert_response :unauthorized
+ assert_equal "invalid_token", body["error"]
+ end
+
+ test "a key nobody minted is refused" do
+ ask("nemo_live_neverissuedatall12")
+
+ assert_response :unauthorized
+ assert_equal "invalid_token", body["error"]
+ end
+
+ test "a header that is not a bearer is refused" do
+ get api_v1_token_path, headers: { "Authorization" => "Basic #{@secret}" }
+
+ assert_response :unauthorized
+ assert_equal "invalid_token", body["error"]
+ end
+
+ test "a revoked key says so, so a caller stops retrying" do
+ @token.revoke!(by: "UOWNER1")
+ ask
+
+ assert_response :unauthorized
+ assert_equal "revoked_token", body["error"]
+ end
+
+ test "the whole api is shut while the flag is off, even with a good key" do
+ Fd::Flag.set!(:public_api, false, by: "UBOSS")
+ ask
+
+ assert_response :service_unavailable
+ assert_equal "api_off", body["error"]
+ end
+
+ test "using a key stamps it, and does not stamp it again on every call" do
+ assert_nil @token.last_used_at
+
+ ask
+ first = @token.reload.last_used_at
+ assert_not_nil first
+
+ ask
+ assert_equal first, @token.reload.last_used_at, "one write a minute, not one a request"
+ end
+
+ test "a signed in browser session opens nothing on the api" do
+ staff = Staff.create!(user_id: "UBOSS2", community_manager: true)
+ sign_in_as(staff)
+
+ get api_v1_token_path
+
+ assert_response :unauthorized
+ assert_equal "invalid_token", body["error"]
+ end
+
+ test "every refusal answers in json, never a redirect or a page" do
+ Fd::Flag.set!(:public_api, false, by: "UBOSS")
+ ask(nil)
+
+ assert_equal "application/json", response.media_type
+ assert body.key?("message"), "a caller is told what to do about it"
+ end
+end
diff --git a/web/test/integration/api_channel_managers_test.rb b/web/test/integration/api_channel_managers_test.rb
new file mode 100644
index 00000000..1d744ee4
--- /dev/null
+++ b/web/test/integration/api_channel_managers_test.rb
@@ -0,0 +1,158 @@
+require "test_helper"
+
+class ApiChannelManagersTest < ActionDispatch::IntegrationTest
+ MANAGER = "U0BGRUHPTTR".freeze
+ BYSTANDER = "U04KX9TQ2AA".freeze
+
+ setup do
+ Fd::Flag.set!(:public_api, true, by: "UBOSS")
+ @token, @secret = Api::Token.mint!("UOWNER1", "Toolbox")
+ @channel = Analytics::DimChannel.first.channel_id
+ Api::ChannelManager.delete_all
+ Api::ChannelSweep.delete_all
+ Api::RequestLog.delete_all
+ Api::Consent.delete_all
+ manages(MANAGER)
+ end
+
+ teardown do
+ Fd::Flag.delete_all
+ Current.forget_flags
+ end
+
+ def manages(user_id, on: @channel)
+ Api::ChannelManager.create!(channel_id: on, user_id: user_id, assigned_at: 1.year.ago)
+ Api::ChannelSweep.stamp!(on, 1)
+ end
+
+ def opted_in(user_id)
+ Api::Consent.set!(user_id, "channel_manager", true, via: "dashboard")
+ end
+
+ def head(key = @secret)
+ { "Authorization" => "Bearer #{key}" }
+ end
+
+ def ask(user_id, on: @channel)
+ get api_v1_channel_manager_path(channel_id: on, user_id: user_id), headers: head
+ end
+
+ def body = JSON.parse(response.body)
+
+ def body_of
+ yield
+ body
+ end
+
+ test "an opted in manager is answered yes, with when and how fresh" do
+ opted_in(MANAGER)
+ ask(MANAGER)
+
+ assert_response :success
+ assert_equal [@channel, MANAGER, "granted", true], body.values_at("channel_id", "user_id",
+ "consent", "is_manager")
+ assert_not body["stale"]
+ assert body["synced_at"].present?
+ assert_not body.key?("since"), "when somebody became a manager is not the caller's business"
+ end
+
+ test "an opted in bystander is answered no" do
+ opted_in(BYSTANDER)
+ ask(BYSTANDER)
+
+ assert_response :success
+ assert_equal ["granted", false], body.values_at("consent", "is_manager")
+ end
+
+ test "somebody who never opted in is withheld, and told nothing else" do
+ ask(MANAGER)
+
+ assert_response :success
+ assert_equal "withheld", body["consent"]
+ assert_nil body["is_manager"]
+ assert_match(%r{/you/api\z}, body["opt_in_url"])
+ end
+
+ test "withheld looks the same whether or not they manage it" do
+ ask(MANAGER)
+ managing = body
+
+ ask(BYSTANDER)
+
+ assert_equal managing.except("user_id"), body.except("user_id"),
+ "the shape must not leak the answer it is withholding"
+ end
+
+ test "a withheld ask never carries freshness, because nothing was read" do
+ ask(MANAGER)
+
+ assert_not body.key?("synced_at")
+ assert_not body.key?("stale")
+ end
+
+ test "a private channel is answered like any other, because the member opted in" do
+ opted_in(MANAGER)
+ manages(MANAGER, on: "C0PRIVATE99")
+ ask(MANAGER, on: "C0PRIVATE99")
+
+ assert_response :success
+ assert_equal ["granted", true], body.values_at("consent", "is_manager")
+ end
+
+ test "a channel we hold nothing on answers no, not whether it exists" do
+ opted_in(MANAGER)
+ ask(MANAGER, on: "C0NOTHINGHERE")
+
+ assert_response :success
+ assert_equal ["granted", false], body.values_at("consent", "is_manager")
+ end
+
+ test "consent is settled before the channel, so an opted out ask reveals no channel" do
+ real = body_of { ask(MANAGER, on: @channel) }
+ made_up = body_of { ask(MANAGER, on: "C0NOSUCHTHING") }
+
+ assert_equal "withheld", real["consent"]
+ assert_equal real.except("channel_id"), made_up.except("channel_id"),
+ "a private or unknown channel must look the same while consent is withheld"
+ end
+
+ test "a malformed id is refused before anything is read" do
+ ask("nope")
+ assert_response :unprocessable_content
+ assert_equal "bad_user_id", body["error"]
+
+ ask(MANAGER, on: "nope")
+ assert_response :unprocessable_content
+ assert_equal "bad_channel_id", body["error"]
+ assert_empty Api::RequestLog.all, "a malformed id is not an ask about anybody"
+ end
+
+ test "every ask is written down, with what it was told" do
+ opted_in(MANAGER)
+ ask(MANAGER)
+ ask(BYSTANDER)
+
+ logged = Api::RequestLog.order(:id).pluck(:subject_user_id, :outcome, :channel_id)
+ assert_equal [[MANAGER, "manager", @channel], [BYSTANDER, "withheld", @channel]], logged
+ assert_equal [@token.id], Api::RequestLog.distinct.pluck(:token_id)
+ end
+
+ test "a withheld ask reads nothing about the channel" do
+ asked = []
+ was = ChannelManagers.method(:freshen)
+ ChannelManagers.define_singleton_method(:freshen) { |id| asked << id }
+ ask(MANAGER)
+
+ assert_empty asked, "consent is checked before slack is ever troubled"
+ assert_equal 1, Api::RequestLog.where(outcome: "withheld").count
+ ensure
+ ChannelManagers.define_singleton_method(:freshen, was)
+ end
+
+ test "the api still needs a token for a check" do
+ get api_v1_channel_manager_path(channel_id: @channel, user_id: MANAGER)
+
+ assert_response :unauthorized
+ assert_empty Api::RequestLog.all
+ end
+end
diff --git a/web/test/integration/api_docs_test.rb b/web/test/integration/api_docs_test.rb
new file mode 100644
index 00000000..58a65499
--- /dev/null
+++ b/web/test/integration/api_docs_test.rb
@@ -0,0 +1,65 @@
+require "test_helper"
+
+class ApiDocsTest < ActionDispatch::IntegrationTest
+ setup do
+ @member = Staff.create!(user_id: "UMEMBER3")
+ end
+
+ test "any signed in member can read the docs, role or no role" do
+ sign_in_as(@member)
+ get docs_path
+
+ assert_response :success
+ assert_select ".docs-sec", Docs.section_ids.size
+ assert_select ".rail-item[href=?]", docs_path
+ end
+
+ test "signed out, the docs ask you to sign in" do
+ get docs_path
+
+ assert_redirected_to login_path
+ end
+
+ test "the rate it quotes is the one actually in force" do
+ Api::Setting.set!("rate_per_minute", 45, by: "UBOSS")
+ sign_in_as(@member)
+ get docs_path
+
+ assert_select ".docs-meta dd", text: "45/min per key"
+ assert_select ".pre", text: /RateLimit-Limit: 45/
+ end
+
+ test "every error the api can return is written down, and nothing else" do
+ sign_in_as(@member)
+ get docs_path
+
+ listed = css_select("#errors .data-table tbody td:nth-child(2)").map(&:text)
+ assert_equal Api::V1::BaseController::CALLER_ERRORS.map { |_status, key, _said| key }.sort,
+ listed.sort
+ end
+
+ test "the contents and the page cannot drift apart" do
+ sign_in_as(@member)
+ get docs_path
+
+ listed = css_select(".docs-nav a").map { |link| link["href"].delete_prefix("#") }
+ rendered = css_select(".docs-sec").map { |sec| sec["id"] }
+
+ assert_equal Docs.section_ids, listed, "the contents list every section, in order"
+ assert_equal Docs.section_ids, rendered, "and every one of them is on the page"
+ end
+
+ test "a topic names its sections once, so a duplicate anchor cannot creep in" do
+ assert_equal Docs.section_ids.uniq, Docs.section_ids
+ end
+
+ test "the docs never name a key somebody actually holds" do
+ sign_in_as(@member)
+ _token, secret = Api::Token.mint!(@member.user_id, "Toolbox")
+ get docs_path
+
+ assert_no_match(/#{Regexp.escape(secret)}/, response.body,
+ "a real key must not leak into the examples")
+ assert_match(/nemo_live_7Fj2/, response.body, "the example key is a made up one")
+ end
+end
diff --git a/web/test/integration/api_rate_limit_test.rb b/web/test/integration/api_rate_limit_test.rb
new file mode 100644
index 00000000..ca43efd1
--- /dev/null
+++ b/web/test/integration/api_rate_limit_test.rb
@@ -0,0 +1,110 @@
+require "test_helper"
+
+class ApiRateLimitTest < ActionDispatch::IntegrationTest
+ setup do
+ Fd::Flag.set!(:public_api, true, by: "UBOSS")
+ @token, @secret = Api::Token.mint!("UOWNER1", "Toolbox")
+ end
+
+ teardown do
+ Fd::Flag.delete_all
+ Current.forget_flags
+ end
+
+ def ask(key = @secret)
+ get api_v1_token_path, headers: { "Authorization" => "Bearer #{key}" }
+ end
+
+ def budget
+ response.headers.slice("RateLimit-Limit", "RateLimit-Remaining", "RateLimit-Reset")
+ end
+
+ def body = JSON.parse(response.body)
+
+ test "the default budget is twenty a minute" do
+ assert_equal 20, Api::Setting.value("rate_per_minute")
+ assert_equal 20, @token.rate
+ end
+
+ test "every answer says how much budget is left" do
+ with_a_real_cache do
+ ask
+
+ assert_response :success
+ assert_equal "20", budget["RateLimit-Limit"]
+ assert_equal "19", budget["RateLimit-Remaining"]
+ assert budget["RateLimit-Reset"].to_i.between?(1, 60)
+ end
+ end
+
+ test "the remaining budget counts down, and stops at nought" do
+ with_a_real_cache do
+ 3.times { ask }
+ assert_equal "17", budget["RateLimit-Remaining"]
+
+ 20.times { ask }
+ assert_equal "0", budget["RateLimit-Remaining"], "it never goes negative"
+ end
+ end
+
+ test "past the budget it refuses, and says when to come back" do
+ with_a_real_cache do
+ 20.times { ask }
+ assert_response :success
+
+ ask
+
+ assert_response :too_many_requests
+ assert_equal "rate_limited", body["error"]
+ assert body["retry_after"].to_i.between?(1, 60)
+ assert_equal response.headers["Retry-After"], body["retry_after"].to_s
+ end
+ end
+
+ test "one token running hot does not spend another token's budget" do
+ with_a_real_cache do
+ other, spare = Api::Token.mint!("UOWNER2", "Arcade")
+ 21.times { ask }
+ assert_response :too_many_requests
+
+ ask(spare)
+
+ assert_response :success
+ assert_equal "19", budget["RateLimit-Remaining"]
+ assert_equal other.rate, budget["RateLimit-Limit"].to_i
+ end
+ end
+
+ test "a token with its own limit is held to that, not the shared one" do
+ with_a_real_cache do
+ @token.update!(rate_limit: 2)
+
+ 2.times { ask }
+ assert_response :success
+ assert_equal "2", budget["RateLimit-Limit"]
+
+ ask
+ assert_response :too_many_requests
+ end
+ end
+
+ test "a refusal before the token is known carries no budget at all" do
+ with_a_real_cache do
+ ask("nemo_live_neverissuedatall12")
+
+ assert_response :unauthorized
+ assert_empty budget, "there is no budget to report without a token"
+ end
+ end
+
+ test "the budget is spent on checks too, not only on the token route" do
+ with_a_real_cache do
+ channel = Analytics::DimChannel.first.channel_id
+ get api_v1_channel_manager_path(channel_id: channel, user_id: "U0BGRUHPTTR"),
+ headers: { "Authorization" => "Bearer #{@secret}" }
+
+ assert_response :success
+ assert_equal "19", budget["RateLimit-Remaining"]
+ end
+ end
+end
diff --git a/web/test/integration/fd_access_test.rb b/web/test/integration/fd_access_test.rb
index 8f33695a..3457dde0 100644
--- a/web/test/integration/fd_access_test.rb
+++ b/web/test/integration/fd_access_test.rb
@@ -66,7 +66,7 @@ def self.enforced
end.map(&:first).uniq
assert_equal %w[fd/settlements fd/supersessions fd/retirements fd/grants
- fd/role_permissions fd/flags].sort,
+ fd/role_permissions fd/flags fd/api_settings].sort,
lead_only.sort,
"a lead-only route appeared or vanished, so this test needs updating"
end
diff --git a/web/test/integration/fd_api_settings_test.rb b/web/test/integration/fd_api_settings_test.rb
new file mode 100644
index 00000000..c695f282
--- /dev/null
+++ b/web/test/integration/fd_api_settings_test.rb
@@ -0,0 +1,97 @@
+require "test_helper"
+
+class FdApiSettingsTest < ActionDispatch::IntegrationTest
+ setup do
+ @boss = Staff.create!(user_id: "UBOSS9", community_manager: true)
+ Fd::AccessGrant.give!("UFIRE9", role: "firefighter", by: @boss.user_id)
+ @token, = Api::Token.mint!("UOWNER9", "Toolbox")
+ sign_in_as(@boss)
+ end
+
+ def dials
+ Api::Setting::DEFAULTS.keys.index_with { |key| Api::Setting.value(key) }
+ end
+
+ test "the tab lists every token with its owner and its rate" do
+ get fd_settings_path(tab: "api")
+
+ assert_response :success
+ assert_select ".data-table td", text: "Toolbox"
+ assert_select ".data-table td.mono", text: @token.shown
+ assert_select ".fbox .row-k", text: "Requests a minute, per token"
+ end
+
+ test "a manager moves a dial, and it is written down" do
+ assert_difference -> { Api::Event.count }, 1 do
+ patch fd_api_setting_path, params: { key: "rate_per_minute", value: 250 }
+ end
+
+ assert_equal 250, Api::Setting.value("rate_per_minute")
+ said = Api::Event.last
+ assert_equal ["setting_changed", @boss.user_id, "20 to 250"],
+ [said.verb, said.actor_user_id, said.detail]
+ end
+
+ test "a dial that does not exist, or a silly number, changes nothing" do
+ was = dials
+
+ patch fd_api_setting_path, params: { key: "wingspan", value: 5 }
+ assert_match(/not a setting/, flash[:alert])
+
+ patch fd_api_setting_path, params: { key: "rate_per_minute", value: 0 }
+ assert_match(/above nought/, flash[:alert])
+
+ patch fd_api_setting_path, params: { key: "rate_per_minute", value: 999_999_999 }
+ assert_match(/more than anybody needs/, flash[:alert])
+
+ assert_equal was, dials
+ assert_equal 0, Api::Event.count
+ end
+
+ test "a manager gives one token its own rate, and takes it away again" do
+ patch fd_api_token_rate_path(@token), params: { value: 600 }
+ assert_equal 600, @token.reload.rate
+
+ patch fd_api_token_rate_path(@token), params: { value: "" }
+ assert_nil @token.reload.rate_limit
+ assert_equal Api::Setting.value("rate_per_minute"), @token.rate
+ assert_equal 2, Api::Event.where(verb: "token_rate_set").count
+ end
+
+ test "a manager revokes somebody else's token, and it is named to them" do
+ delete fd_api_token_path(@token)
+
+ assert_predicate @token.reload, :revoked?
+ assert_equal @boss.user_id, @token.revoked_by
+ said = Api::Event.where(verb: "token_revoked").sole
+ assert_match(/owned by UOWNER9/, said.detail)
+ end
+
+ test "revoking the same token twice is refused rather than logged twice" do
+ delete fd_api_token_path(@token)
+
+ assert_no_difference -> { Api::Event.count } do
+ delete fd_api_token_path(@token)
+ end
+
+ assert_match(/no live token/, flash[:alert])
+ end
+
+ test "a firefighter cannot move a dial or touch a token" do
+ sign_in_as(Staff.find("UFIRE9"))
+
+ patch fd_api_setting_path, params: { key: "rate_per_minute", value: 999 }
+ delete fd_api_token_path(@token)
+
+ assert_equal 20, Api::Setting.value("rate_per_minute")
+ assert_not_predicate @token.reload, :revoked?
+ assert_equal 0, Api::Event.count
+ end
+
+ test "a firefighter does not even see the tab" do
+ sign_in_as(Staff.find("UFIRE9"))
+ get fd_settings_path(tab: "api")
+
+ assert_select ".views .view", text: /API/, count: 0
+ end
+end
diff --git a/web/test/integration/member_area_test.rb b/web/test/integration/member_area_test.rb
new file mode 100644
index 00000000..932da661
--- /dev/null
+++ b/web/test/integration/member_area_test.rb
@@ -0,0 +1,120 @@
+require "test_helper"
+
+class MemberAreaTest < ActionDispatch::IntegrationTest
+ setup do
+ @member = Staff.create!(user_id: "UMEMBER1")
+ @staff = Staff.create!(user_id: "UBOSS1", community_manager: true)
+ end
+
+ def gated_paths
+ [root_path, fd_root_path, fd_cases_path, fd_members_path, fd_settings_path,
+ fd_audit_path, fd_decisions_path, fd_search_path, channels_path, engine_path,
+ acquisition_journey_path]
+ end
+
+ test "a member with no role signs in and lands on their own page" do
+ sign_in_as(@member)
+
+ assert_redirected_to you_api_path
+ follow_redirect!
+ assert_response :success
+ end
+
+ test "a member with no role holds no permission at all" do
+ assert_nil @member.role
+ Fd::Permission.keys.each do |key|
+ assert_not @member.may?(key), "a member with no role must not hold #{key}"
+ end
+ end
+
+ test "every other page is still shut to a member with no role" do
+ sign_in_as(@member)
+
+ gated_paths.each do |path|
+ get path
+ assert_redirected_to auth_failure_path(message: "not_allowlisted"),
+ "#{path} let a member with no role through"
+ end
+ end
+
+ test "a member with no role cannot write to fire engine either" do
+ kase = make_case
+
+ sign_in_as(@member)
+ post fd_case_claim_path(kase)
+
+ assert_redirected_to auth_failure_path(message: "not_allowlisted")
+ assert_empty kase.reload.assignees
+ end
+
+ test "a json request from a member with no role is refused, not redirected" do
+ sign_in_as(@member)
+ get fd_search_path(format: :json)
+
+ assert_response :unauthorized
+ end
+
+ test "signed out, the member page sends you to sign in" do
+ get you_api_path
+
+ assert_redirected_to login_path
+ end
+
+ test "signing out shuts the member page again" do
+ sign_in_as(@member)
+ get you_api_path
+ assert_response :success
+
+ delete logout_path
+ get you_api_path
+
+ assert_redirected_to login_path
+ end
+
+ test "an identity that is not a slack id is given no session at all" do
+ OmniAuth.config.test_mode = true
+ OmniAuth.config.mock_auth[:hackclub] = OmniAuth::AuthHash.new(
+ provider: "hackclub", uid: "ident!nonsense", info: {},
+ extra: { raw_info: { "slack_id" => "../../etc/passwd" } }
+ )
+ get "/auth/hackclub/callback"
+
+ assert_redirected_to auth_failure_path(message: "no_slack_id")
+
+ get you_api_path
+ assert_redirected_to login_path
+ end
+
+ test "the member page shows no fire engine and no analytics in the rail" do
+ sign_in_as(@member)
+ get you_api_path
+
+ assert_select ".rail-item[href=?]", you_api_path
+ assert_select ".rail-item[href=?]", fd_cases_path, count: 0
+ assert_select ".rail-item[href=?]", channels_path, count: 0
+ assert_select ".rail-item[href=?]", engine_path, count: 0
+ assert_select ".rail-find", { count: 0 }, "the search palette is firefighters only"
+ end
+
+ test "a firefighter keeps their whole rail and gains the account section" do
+ sign_in_as(@staff)
+ get you_api_path
+
+ assert_response :success
+ assert_select ".rail-item[href=?]", you_api_path
+ assert_select ".rail-item[href=?]", fd_cases_path
+ end
+
+ test "signing in as a firefighter still lands on the dashboard, not the member page" do
+ sign_in_as(@staff)
+
+ assert_redirected_to root_path
+ end
+
+ test "the sign in page sends a signed in member on rather than looping" do
+ sign_in_as(@member)
+ get login_path
+
+ assert_redirected_to you_api_path
+ end
+end
diff --git a/web/test/integration/member_consent_test.rb b/web/test/integration/member_consent_test.rb
new file mode 100644
index 00000000..ab88f11e
--- /dev/null
+++ b/web/test/integration/member_consent_test.rb
@@ -0,0 +1,113 @@
+require "test_helper"
+
+class MemberConsentTest < ActionDispatch::IntegrationTest
+ CAP = "channel_manager".freeze
+
+ setup do
+ Fd::Flag.set!(:public_api, true, by: "UBOSS")
+ @member = Staff.create!(user_id: "UMEMBER2")
+ sign_in_as(@member)
+ end
+
+ teardown do
+ Fd::Flag.delete_all
+ Current.forget_flags
+ end
+
+ def flip(on, **extra)
+ patch you_consent_path(capability: CAP, on: on, **extra)
+ end
+
+ def state
+ Api::Consent.find_by(user_id: @member.user_id, capability: CAP)&.state
+ end
+
+ test "opting in writes the consent and exactly one log line" do
+ assert_difference -> { Api::ConsentLog.count }, 1 do
+ flip("1")
+ end
+
+ assert_equal "granted", state
+ assert_equal "dashboard", Api::ConsentLog.last.via
+ assert_equal @member.user_id, Api::ConsentLog.last.user_id
+ end
+
+ test "opting out again withholds it and logs the second move" do
+ flip("1")
+ assert_difference -> { Api::ConsentLog.count }, 1 do
+ flip("0")
+ end
+
+ assert_equal "withheld", state
+ assert_equal %w[granted withheld], Api::ConsentLog.order(:at, :id).pluck(:state)
+ end
+
+ test "the first grant is remembered even after opting out" do
+ flip("1")
+ first = Api::Consent.find_by(user_id: @member.user_id, capability: CAP).first_granted_at
+ flip("0")
+
+ assert_equal first, Api::Consent.find_by(user_id: @member.user_id, capability: CAP)
+ .first_granted_at
+ end
+
+ test "a member can only ever move their own row" do
+ flip("1", user_id: "UVICTIM", member_id: "UVICTIM")
+
+ assert_equal "granted", state
+ assert_nil Api::Consent.find_by(user_id: "UVICTIM")
+ assert_empty Api::ConsentLog.where(user_id: "UVICTIM")
+ end
+
+ test "a capability nobody declared is refused and writes nothing" do
+ assert_no_difference -> { Api::ConsentLog.count } do
+ patch you_consent_path(capability: "read_my_email", on: "1")
+ end
+
+ assert_redirected_to you_api_path
+ assert_match(/not a capability/, flash[:alert])
+ assert_empty Api::Consent.all
+ end
+
+ test "nothing moves while the public api is turned off" do
+ Fd::Flag.set!(:public_api, false, by: "UBOSS")
+
+ assert_no_difference -> { Api::ConsentLog.count } do
+ flip("1")
+ end
+
+ assert_nil state
+ assert_match(/turned off/, flash[:alert])
+ end
+
+ test "the page offers opting in, then opting out, and counts what is on" do
+ get you_api_path
+
+ assert_select ".cap-row .btn", text: "Opt in"
+ assert_select ".facts .frow b", text: /nothing/
+
+ flip("1")
+ get you_api_path
+
+ assert_select ".cap-row .btn", text: "Opt out"
+ assert_select ".facts .frow b", text: /1 of 1/
+ end
+
+ test "the switch is dead on the page while the public api is off" do
+ Fd::Flag.set!(:public_api, false, by: "UBOSS")
+ get you_api_path
+
+ assert_select ".cap-row .btn.is-off"
+ assert_select ".cap-row .btn[disabled]"
+ end
+
+ test "signed out, nobody can move consent at all" do
+ delete logout_path
+
+ assert_no_difference -> { Api::ConsentLog.count } do
+ flip("1")
+ end
+
+ assert_redirected_to login_path
+ end
+end
diff --git a/web/test/integration/member_tokens_test.rb b/web/test/integration/member_tokens_test.rb
new file mode 100644
index 00000000..fa97482b
--- /dev/null
+++ b/web/test/integration/member_tokens_test.rb
@@ -0,0 +1,121 @@
+require "test_helper"
+
+class MemberTokensTest < ActionDispatch::IntegrationTest
+ setup do
+ Fd::Flag.set!(:public_api, true, by: "UBOSS")
+ @member = Staff.create!(user_id: "UMEMBER4")
+ sign_in_as(@member)
+ end
+
+ teardown do
+ Fd::Flag.delete_all
+ Current.forget_flags
+ end
+
+ def mint(name: "Toolbox", lasting: "90")
+ post you_tokens_path, params: { name: name, lasting: lasting }
+ end
+
+ test "the form does not go through turbo, so the answer is not thrown away" do
+ get you_api_path(tab: "tokens")
+
+ assert_select "form[action=?][data-turbo=false]", you_tokens_path, 1,
+ "turbo drive discards a 200 from a form post, and the key would never be seen"
+ end
+
+ test "creating a key renders the one and only time it is shown" do
+ assert_difference -> { Api::Token.count }, 1 do
+ mint
+ end
+
+ assert_response :success
+ assert_select ".modal-title", text: "Copy your key"
+ assert_select ".secret code", 1
+ assert_select ".warn-line"
+ end
+
+ test "the key on screen is the real one, and is not what we stored" do
+ mint
+ shown = css_select(".secret code").sole.text
+ token = Api::Token.sole
+
+ assert_equal Api::Token.digest_of(shown), token.digest
+ assert_not_equal shown, token.digest
+ assert shown.start_with?(Api::Token::LEAD)
+ end
+
+ test "the chosen expiry is the one written down" do
+ mint(lasting: "30")
+
+ assert_in_delta 30.days.from_now, Api::Token.sole.expires_at, 1.minute
+ end
+
+ test "no expiry is a real choice, not a default" do
+ mint(lasting: "never")
+
+ assert_nil Api::Token.sole.expires_at
+ end
+
+ test "a made up expiry falls back rather than storing nothing" do
+ mint(lasting: "forever and ever")
+
+ assert_in_delta 90.days.from_now, Api::Token.sole.expires_at, 1.minute
+ end
+
+ test "an unnamed key is refused" do
+ assert_no_difference -> { Api::Token.count } do
+ mint(name: " ")
+ end
+
+ assert_redirected_to you_api_path(tab: "tokens")
+ end
+
+ test "minting is written to the audit log with its life" do
+ mint(lasting: "30")
+
+ said = Api::Event.where(verb: "token_minted").sole
+ assert_equal @member.user_id, said.actor_user_id
+ assert_equal "Toolbox, 30 days", said.detail
+ end
+
+ test "past the cap it is refused, and a revoked key frees a slot" do
+ 3.times { |i| mint(name: "key #{i}") }
+ assert_equal 3, Api::Token.count
+
+ mint(name: "one too many")
+ assert_equal 3, Api::Token.count
+ assert_match(/already hold 3/, flash[:alert])
+
+ Api::Token.first.revoke!(by: @member.user_id)
+ mint(name: "room now")
+
+ assert_equal 4, Api::Token.count
+ end
+
+ test "an expired key does not hold a slot" do
+ 3.times { |i| mint(name: "key #{i}") }
+ Api::Token.first.update!(expires_at: 1.day.ago)
+
+ mint(name: "room now")
+
+ assert_equal 4, Api::Token.count
+ end
+
+ test "nobody can mint while the public api is off" do
+ Fd::Flag.set!(:public_api, false, by: "UBOSS")
+
+ assert_no_difference -> { Api::Token.count } do
+ mint
+ end
+ end
+
+ test "signed out, nobody can mint at all" do
+ delete logout_path
+
+ assert_no_difference -> { Api::Token.count } do
+ mint
+ end
+
+ assert_redirected_to login_path
+ end
+end
diff --git a/web/test/integration/who_gets_in_test.rb b/web/test/integration/who_gets_in_test.rb
index 5a38f5fe..11750434 100644
--- a/web/test/integration/who_gets_in_test.rb
+++ b/web/test/integration/who_gets_in_test.rb
@@ -8,6 +8,10 @@ class WhoGetsInTest < ActionDispatch::IntegrationTest
INSIDE = %i[root_path fd_root_path fd_members_path fd_decisions_path fd_settings_path].freeze
+ MEMBER = %w[you/api you/consents you/tokens docs].freeze
+
+ BEARER = %w[api/v1/tokens api/v1/channel_managers].freeze
+
setup do
Rails.application.eager_load!
@me = Staff.create!(user_id: "UNOROLE", community_manager: false)
@@ -17,13 +21,26 @@ def self.controllers
Rails.application.routes.routes.filter_map { |route| route.defaults[:controller] }.uniq
end
+ def filters_of(name)
+ "#{name}_controller".camelize.constantize._process_action_callbacks.map(&:filter)
+ end
+
def guarded?(name)
- "#{name}_controller".camelize.constantize._process_action_callbacks
- .any? { |callback| callback.filter == :require_staff }
+ filters_of(name).include?(:require_staff)
+ end
+
+ def member_guarded?(name)
+ filters_of(name).include?(:require_a_member)
+ end
+
+ def token_guarded?(name)
+ filters_of(name).include?(:require_a_token)
end
def guarded_controllers
- (self.class.controllers - OPEN).select { |name| guarded?(name) }
+ (self.class.controllers - OPEN).select do |name|
+ guarded?(name) || member_guarded?(name) || token_guarded?(name)
+ end
end
test "the only routes open to the world are signing in and the health check" do
@@ -31,10 +48,40 @@ def guarded_controllers
"a route opened up or closed, so this test needs updating"
end
- test "every controller behind the login demands a role, not merely a session" do
- (self.class.controllers - OPEN).each do |name|
+ test "every controller behind the login demands a role, bar the member area and the api" do
+ (self.class.controllers - OPEN - MEMBER - BEARER).each do |name|
assert guarded?(name), "#{name} lets anybody through"
+ assert_not member_guarded?(name), "#{name} settles for a session where a role is needed"
+ end
+ end
+
+ test "the member area demands a session and never a role, and is only what is listed" do
+ MEMBER.each do |name|
+ assert member_guarded?(name), "#{name} lets anybody through"
+ assert_not guarded?(name), "#{name} still demands a role, so it is not a member page"
+ end
+
+ assert_equal MEMBER.sort,
+ (self.class.controllers - OPEN - BEARER).reject { |name| guarded?(name) }.sort,
+ "a controller dropped its role check, so this test needs updating"
+ end
+
+ test "the api demands a token, never a session or a role, and is only what is listed" do
+ BEARER.each do |name|
+ assert token_guarded?(name), "#{name} lets anybody through"
+ assert_not guarded?(name), "#{name} demands a role, so it is not reachable by a token"
+ assert_not member_guarded?(name), "#{name} demands a session, which an api caller has not got"
end
+
+ assert_equal BEARER.sort,
+ (self.class.controllers - OPEN).select { |name| token_guarded?(name) }.sort,
+ "a controller started taking bearer tokens, so this test needs updating"
+ end
+
+ test "the api carries no session at all, so a browser cannot ride in on cookies" do
+ assert_not Api::V1::BaseController.ancestors.include?(ActionController::Cookies),
+ "an api controller that reads cookies can be driven by a logged in browser"
+ assert_not Api::V1::BaseController.ancestors.include?(ApplicationController)
end
test "the turbo routes are open because they carry nothing but a go back" do
@@ -45,11 +92,17 @@ def guarded_controllers
end
end
- test "holding no grant means no session, whatever the staff table says" do
+ test "holding no grant opens the member area and nothing behind it" do
sign_in_as(@me)
- assert_redirected_to auth_failure_path(message: "not_allowlisted")
- assert_nil session[:user_id]
+ assert_redirected_to you_api_path
+ assert_equal "UNOROLE", session[:user_id]
+
+ INSIDE.each do |path|
+ get send(path)
+ assert_redirected_to auth_failure_path(message: "not_allowlisted"),
+ "#{path} took a session for a role"
+ end
end
test "a live grant is enough on its own, with no staff row behind it" do
@@ -64,21 +117,24 @@ def guarded_controllers
assert_response :success
end
- test "somebody unknown to the staff table is refused the same way" do
+ test "somebody unknown to the staff table gets the same member area and no more" do
sign_in_as(Staff.new(user_id: "USTRANGER"))
+ assert_redirected_to you_api_path
+ get fd_cases_path
assert_redirected_to auth_failure_path(message: "not_allowlisted")
- assert_nil session[:user_id]
end
test "the refusal says one thing, and offers the way back" do
sign_in_as(@me)
+ get root_path
follow_redirect!
assert_response :success
assert_select "h1", text: "Access denied"
assert_select "p", text: /You are not allowlisted/
- assert_select "a[href=?]", login_path
+ assert_select "a[href=?]", you_api_path, 1, "the way out is their own page"
+ assert_select "form[action=?]", logout_path
assert_select ".auth-alt", count: 0
end
@@ -111,15 +167,16 @@ def guarded_controllers
assert_redirected_to auth_failure_path(message: "not_allowlisted")
end
- test "a stale session lands on the sign in page rather than bouncing forever" do
+ test "a stale session lands on the member area rather than bouncing forever" do
grant = Fd::AccessGrant.give!("UNOROLE", role: "firefighter", by: "UBOSS")
sign_in_as(@me)
grant.take_back!(by: "UBOSS")
get login_path
+ assert_redirected_to you_api_path
+ follow_redirect!
assert_response :success
- assert_select "h1", text: /sign in/i
end
test "the sign in switch for development is not routed anywhere else" do
diff --git a/web/test/models/channel_managers_test.rb b/web/test/models/channel_managers_test.rb
new file mode 100644
index 00000000..8ca18ab9
--- /dev/null
+++ b/web/test/models/channel_managers_test.rb
@@ -0,0 +1,202 @@
+require "test_helper"
+
+class ChannelManagersTest < ActiveSupport::TestCase
+ CHANNEL = "C0BGRUMA85D".freeze
+
+ setup do
+ @asked = []
+ @was_role = ENV["SLACK_CHANNEL_MANAGER_ROLE_ID"]
+ ENV["SLACK_CHANNEL_MANAGER_ROLE_ID"] = "Rl0A"
+ Api::ChannelManager.delete_all
+ Api::ChannelSweep.delete_all
+ Rails.cache.clear
+ end
+
+ teardown do
+ ENV["SLACK_CHANNEL_MANAGER_ROLE_ID"] = @was_role
+ end
+
+ def swapping(replier)
+ was = Slack::ProxyClient.method(:call)
+ Slack::ProxyClient.define_singleton_method(:call, &replier)
+ yield
+ ensure
+ Slack::ProxyClient.define_singleton_method(:call, was)
+ end
+
+ def page(*user_ids, on: CHANNEL, cursor: nil)
+ {
+ "ok" => true,
+ "role_assignments" => user_ids.map do |user_id|
+ { "role_id" => "Rl0A", "entity_id" => on, "user_id" => user_id,
+ "date_create" => 1_700_000_000 }
+ end,
+ "response_metadata" => { "next_cursor" => cursor.to_s }
+ }
+ end
+
+ def answering(*pages, &block)
+ replies = pages.dup
+ asked = @asked
+ fallback = page
+ swapping(lambda { |method, params, **options|
+ asked << [method, params, options]
+ replies.shift || fallback
+ }, &block)
+ end
+
+ def raising(error, &block)
+ asked = @asked
+ swapping(lambda { |*_args, **_options|
+ asked << :tried
+ raise error
+ }, &block)
+ end
+
+ test "a channel nobody has asked about is fetched once and remembered" do
+ found = answering(page("U1", "U2")) { ChannelManagers.for(CHANNEL) }
+
+ assert_equal %w[U1 U2], found
+ assert_equal 1, @asked.size
+ assert_equal %w[U1 U2], Api::ChannelManager.user_ids_in(CHANNEL)
+ assert_equal 2, Api::ChannelSweep.find(CHANNEL).managers
+ end
+
+ test "it asks slack for one channel and one role, never the whole workspace" do
+ answering(page("U1")) { ChannelManagers.for(CHANNEL) }
+
+ method, params, options = @asked.sole
+ assert_equal "admin.roles.listAssignments", method
+ assert_equal "Rl0A", params[:role_ids]
+ assert_equal CHANNEL, params[:entity_ids]
+ assert_equal "admin", options[:credential]
+ end
+
+ test "a fresh channel is answered without troubling slack at all" do
+ answering(page("U1")) { ChannelManagers.for(CHANNEL) }
+ @asked.clear
+
+ found = answering { ChannelManagers.for(CHANNEL) }
+
+ assert_equal %w[U1], found
+ assert_empty @asked, "a warm channel must cost nothing"
+ end
+
+ test "a channel nobody manages is remembered as empty, not refetched forever" do
+ answering(page) { ChannelManagers.for(CHANNEL) }
+ @asked.clear
+
+ assert_empty answering { ChannelManagers.for(CHANNEL) }
+ assert_empty @asked, "no rows is an answer, not a cold cache"
+ assert_equal 0, Api::ChannelSweep.find(CHANNEL).managers
+ end
+
+ test "once the stamp goes stale it is fetched again" do
+ answering(page("U1")) { ChannelManagers.for(CHANNEL) }
+ Api::ChannelSweep.find(CHANNEL).update!(synced_at: 2.hours.ago)
+ @asked.clear
+
+ found = answering(page("U2")) { ChannelManagers.for(CHANNEL) }
+
+ assert_equal %w[U2], found
+ assert_equal 1, @asked.size
+ end
+
+ test "a refresh replaces the channel, so somebody who stepped down stops managing it" do
+ answering(page("U1", "U2")) { ChannelManagers.for(CHANNEL) }
+ Api::ChannelSweep.find(CHANNEL).update!(synced_at: 2.hours.ago)
+
+ answering(page("U2")) { ChannelManagers.for(CHANNEL) }
+
+ assert_equal %w[U2], Api::ChannelManager.user_ids_in(CHANNEL)
+ assert_not ChannelManagers.manages?(CHANNEL, "U1")
+ end
+
+ test "a stale channel already being refreshed is not fetched a second time" do
+ with_a_real_cache do
+ answering(page("U1")) { ChannelManagers.for(CHANNEL) }
+ Api::ChannelSweep.find(CHANNEL).update!(synced_at: 2.hours.ago)
+ @asked.clear
+ ChannelManagers.claim(CHANNEL)
+
+ found = answering(page("U2")) { ChannelManagers.for(CHANNEL) }
+
+ assert_empty @asked, "somebody else holds the lock, so this caller serves what we have"
+ assert_equal %w[U1], found
+ end
+ end
+
+ test "a cold channel is always fetched, lock or no lock" do
+ with_a_real_cache do
+ ChannelManagers.claim(CHANNEL)
+
+ found = answering(page("U1")) { ChannelManagers.for(CHANNEL) }
+
+ assert_equal %w[U1], found,
+ "answering false about a channel we never read is worse than waiting"
+ end
+ end
+
+ test "a refreshed channel is fresh again, so the next caller asks nothing" do
+ answering(page("U1")) { ChannelManagers.for(CHANNEL) }
+ Api::ChannelSweep.find(CHANNEL).update!(synced_at: 2.hours.ago)
+ @asked.clear
+
+ answering(page("U2")) { ChannelManagers.for(CHANNEL) }
+ answering(page("U3")) { ChannelManagers.for(CHANNEL) }
+
+ assert_equal 1, @asked.size
+ assert_equal %w[U2], Api::ChannelManager.user_ids_in(CHANNEL)
+ end
+
+ test "slack falling over serves what we already hold and leaves the stamp alone" do
+ answering(page("U1")) { ChannelManagers.for(CHANNEL) }
+ stamped = Api::ChannelSweep.find(CHANNEL).synced_at
+ Api::ChannelSweep.find(CHANNEL).update!(synced_at: 2.hours.ago)
+
+ found = raising(Slack::ProxyClient::Unavailable) { ChannelManagers.for(CHANNEL) }
+
+ assert_equal %w[U1], found
+ assert_not_equal stamped, Api::ChannelSweep.find(CHANNEL).synced_at
+ assert_equal %w[U1], Api::ChannelManager.user_ids_in(CHANNEL)
+ end
+
+ test "a refusal from slack does not wipe the channel" do
+ answering(page("U1")) { ChannelManagers.for(CHANNEL) }
+ Api::ChannelSweep.find(CHANNEL).update!(synced_at: 2.hours.ago)
+
+ answering({ "ok" => false, "error" => "missing_scope" }) { ChannelManagers.for(CHANNEL) }
+
+ assert_equal %w[U1], Api::ChannelManager.user_ids_in(CHANNEL)
+ end
+
+ test "an assignment on some other channel is dropped, whatever slack sends" do
+ answering(page("U1", on: "COTHER")) { ChannelManagers.for(CHANNEL) }
+
+ assert_empty Api::ChannelManager.user_ids_in(CHANNEL)
+ assert_equal 0, Api::ChannelSweep.find(CHANNEL).managers
+ end
+
+ test "it follows the cursor to the end" do
+ found = answering(page("U1", cursor: "more"), page("U2")) { ChannelManagers.for(CHANNEL) }
+
+ assert_equal %w[U1 U2], found
+ assert_equal 2, @asked.size
+ assert_nil @asked.first[1][:cursor], "the first call sends no cursor at all"
+ assert_equal "more", @asked.last[1][:cursor]
+ end
+
+ test "with no role id pinned it asks nothing and claims nothing" do
+ ENV["SLACK_CHANNEL_MANAGER_ROLE_ID"] = nil
+ found = answering(page("U1")) { ChannelManagers.for(CHANNEL) }
+
+ assert_empty found
+ assert_empty @asked
+ assert_nil Api::ChannelSweep.find_by(channel_id: CHANNEL)
+ end
+
+ test "a blank channel id is answered without a query" do
+ assert_empty answering { ChannelManagers.for("") }
+ assert_empty @asked
+ end
+end
diff --git a/web/test/models/fd/deeds_test.rb b/web/test/models/fd/deeds_test.rb
index aef76bbf..35c3d7e7 100644
--- a/web/test/models/fd/deeds_test.rb
+++ b/web/test/models/fd/deeds_test.rb
@@ -114,6 +114,140 @@ def deeds(only: nil, **opts)
assert_equal %w[case/resolved case/claimed], deeds(limit: 2).map(&:event)
end
+ def both_kinds
+ audit("case", make_case.id, "opened")
+ AccessLog.create!(actor_id: WHO, subject_user_id: "USUB", field_class: "identity",
+ looked_at: 2.days.ago)
+ end
+
+ def all_three
+ both_kinds
+ Api::Consent.set!(WHO, "channel_manager", true, via: "dashboard")
+ end
+
+ test "a view keeps only its own source, and no view keeps nothing" do
+ all_three
+
+ assert_equal %w[case/opened consent/granted identity/read], deeds.map(&:event).sort
+ assert_equal ["case/opened"], deeds(view: "audit").map(&:event)
+ assert_equal ["identity/read"], deeds(view: "read").map(&:event)
+ assert_equal ["consent/granted"], deeds(view: "api").map(&:event)
+ end
+
+ test "a consent change names the capability and how it was made, not a member link" do
+ Api::Consent.set!(WHO, "channel_manager", true, via: "command")
+
+ row = deeds(view: "api").sole
+ assert_equal ["capability", "channel manager lookups"], [row.kind, row.about]
+ assert_equal ["from Slack", WHO], [row.said, row.actor]
+ assert_nil row.id, "a consent row links nothing, it is about the person who made it"
+ end
+
+ test "opting back out reads as its own line, and both survive" do
+ Api::Consent.set!(WHO, "channel_manager", true, via: "dashboard")
+ Api::Consent.set!(WHO, "channel_manager", false, via: "dashboard")
+
+ assert_equal ["consent/granted", "consent/withheld"], deeds(view: "api").map(&:event).sort,
+ "one line each way, whatever order a shared timestamp puts them in"
+ end
+
+ def checked(token, on: "C0DESIGN99", subjects: ["USUB"], outcome: "manager", at: 1.hour.ago)
+ Api::RequestLog.insert_all(subjects.map do |subject|
+ { token_id: token.id, channel_id: on, subject_user_id: subject,
+ outcome: outcome, at: at }
+ end)
+ end
+
+ test "a day of api traffic reads as one line, not one line a call" do
+ token, = Api::Token.mint!(WHO, "Toolbox")
+ checked(token, subjects: %w[U1 U2 U3])
+
+ row = deeds(view: "api").sole
+ assert_equal ["api/checked", "3 members", WHO], [row.event, row.about, row.actor]
+ assert_equal "Toolbox · 1 channel", row.said
+ end
+
+ test "the roll up counts channels and what was withheld" do
+ token, = Api::Token.mint!(WHO, "Toolbox")
+ checked(token, on: "C0ONE", subjects: %w[U1 U2])
+ checked(token, on: "C0TWO", subjects: %w[U3], outcome: "withheld")
+
+ assert_equal "Toolbox · 2 channels · 1 withheld", deeds(view: "api").sole.said
+ end
+
+ test "two days of traffic are two lines, and two tokens are two more" do
+ mine, = Api::Token.mint!(WHO, "Toolbox")
+ theirs, = Api::Token.mint!("UOTHER", "Arcade")
+ checked(mine, at: 1.hour.ago)
+ checked(mine, at: 2.days.ago)
+ checked(theirs, at: 1.hour.ago)
+
+ assert_equal 3, Fd::Deeds.new(nil, since: 30.days.ago, view: "api").rows.size
+ end
+
+ test "a token owner sees their own traffic and not somebody else's" do
+ mine, = Api::Token.mint!(WHO, "Toolbox")
+ theirs, = Api::Token.mint!("UOTHER", "Arcade")
+ checked(mine)
+ checked(theirs)
+
+ assert_equal ["Toolbox · 1 channel"], deeds(view: "api").map(&:said)
+ end
+
+ test "the api count covers consent, events and traffic alike" do
+ token, = Api::Token.mint!(WHO, "Toolbox")
+ checked(token, subjects: %w[U1 U2])
+ Api::Consent.set!(WHO, "channel_manager", true, via: "dashboard")
+ Api::Event.record!("token_minted", actor: WHO, subject: token.shown, detail: "Toolbox")
+
+ counted = Fd::Deeds.new(WHO, since: 30.days.ago).totals
+ assert_equal 3, counted["api"], "one roll up, one consent line, one event"
+ assert_equal deeds(view: "api").size, counted["api"]
+ end
+
+ test "consent changes are asked for by nobody looking at one permission" do
+ Api::Consent.set!(WHO, "channel_manager", true, via: "dashboard")
+ audit("case", make_case.id, "opened")
+
+ assert_equal ["case/opened"], deeds(only: "case.open").map(&:event)
+ assert_empty deeds(only: "identity.read")
+ end
+
+ test "an unknown view falls back to everything rather than to nothing" do
+ both_kinds
+
+ assert_equal deeds.size, deeds(view: "nonsense").size
+ assert_equal "all", Fd::Deeds.view_for("nonsense")
+ assert_equal "all", Fd::Deeds.view_for(nil)
+ end
+
+ test "the per-view counts add up to the unfiltered total" do
+ all_three
+ counted = Fd::Deeds.new(WHO, since: 30.days.ago).totals
+
+ assert_equal 1, counted["audit"]
+ assert_equal 1, counted["read"]
+ assert_equal 1, counted["api"]
+ assert_equal counted["all"], counted["audit"] + counted["read"] + counted["api"]
+ assert_equal Fd::Deeds.new(WHO, since: 30.days.ago).total, counted["all"]
+ end
+
+ test "counting a view agrees with paging it" do
+ all_three
+ counted = Fd::Deeds.new(WHO, since: 30.days.ago).totals
+
+ Fd::Deeds::VIEWS.each_key do |key|
+ assert_equal deeds(view: key).size, counted.fetch(key),
+ "#{key} counts one way and pages another"
+ end
+ end
+
+ test "a view still counts every source when there is nothing to count" do
+ counted = Fd::Deeds.new(WHO, since: 30.days.ago).totals
+
+ assert_equal({ "audit" => 0, "read" => 0, "api" => 0, "all" => 0 }, counted)
+ end
+
test "the members it mentions are the ones a name is needed for" do
kase = make_case
action = Fd::Action.create!(case_id: kase.id, type_key: "warning", target_user_id: "USUB",
diff --git a/web/test/test_helper.rb b/web/test/test_helper.rb
index 8ce9ff09..568892ad 100644
--- a/web/test/test_helper.rb
+++ b/web/test/test_helper.rb
@@ -26,6 +26,14 @@ def make_case(subject: "USUB", assign: nil, **attrs)
kase
end
+ def with_a_real_cache
+ was = Rails.cache
+ Rails.cache = ActiveSupport::Cache::MemoryStore.new
+ yield
+ ensure
+ Rails.cache = was
+ end
+
def sign_in_as(staff)
OmniAuth.config.test_mode = true
OmniAuth.config.mock_auth[:hackclub] = OmniAuth::AuthHash.new(