diff --git a/app/controllers/api/levelcode/v1/auth_controller.rb b/app/controllers/api/levelcode/v1/auth_controller.rb index dafd7ea7..47ee7703 100644 --- a/app/controllers/api/levelcode/v1/auth_controller.rb +++ b/app/controllers/api/levelcode/v1/auth_controller.rb @@ -237,12 +237,8 @@ def safe_redirect_uri(raw) return nil if raw.blank? uri = URI.parse(raw) - # Editor deep-link: host = extension id (levelcode.levelcode-ai), path pinned. Accept the - # current `levelcode` scheme and the legacy `atom-plus-plus` (pre-rename builds) during the - # transition; host + path stay pinned so this can't become an open redirect. - if %w[levelcode atom-plus-plus].include?(uri.scheme) && uri.host == "levelcode.levelcode-ai" && uri.path == "/auth/callback" - return uri - end + # The editor's deep link. Levelcode::EditorCallback has the rule, shared with the /ai flows. + return uri if ::Levelcode::EditorCallback.current.match?(uri) site_host = URI(ENV["SITE_ORIGIN"].presence || "https://levelcode.ai").host return uri if uri.scheme == "https" && uri.host == site_host diff --git a/app/controllers/levelcode/web_controller.rb b/app/controllers/levelcode/web_controller.rb index 866ebb90..73f12dbb 100644 --- a/app/controllers/levelcode/web_controller.rb +++ b/app/controllers/levelcode/web_controller.rb @@ -29,14 +29,6 @@ class WebController < ApplicationController before_action :authenticate_user!, only: %i[checkout billing authorize_editor] - # The frozen editor deep-link callback (SHARED CONTRACT). Host = the extension id - # . = levelcode.levelcode-ai; path = /auth/callback. - EDITOR_CALLBACK = "levelcode://levelcode.levelcode-ai/auth/callback" - # Accepted url schemes for the editor deep-link. `levelcode` is the current product urlProtocol; - # `atom-plus-plus` is the pre-rename scheme, still emitted by editor builds not yet rebuilt — - # accepted during the transition. The security-relevant host + path stay strictly pinned. - EDITOR_SCHEMES = %w[levelcode atom-plus-plus].freeze - # Per-IP OTP-send cap. The recipient is caller-chosen, so EmailCode's per-email # resend window alone can be fanned out across many addresses — bound by source IP. EMAIL_SEND_WINDOW = 1.hour.to_i @@ -272,7 +264,7 @@ def editor_callback_with_code(uri_str, code) # --- redirect_uri validation (no open redirect, never tokens in a URL) ---- # - # Accept ONLY the editor deep-link, pinned by scheme + host + path. We must + # Accept ONLY the editor deep-link (Levelcode::EditorCallback has the rule). We must # tolerate a query string (VS Code's asExternalUri appends ?windowId=N to route # the callback to the right editor window) and percent-encoding (the value can # arrive single- OR double-encoded through the login → OAuth hops), but NOTHING @@ -298,17 +290,10 @@ def decode_deep_link(raw) s end - # True when uri_str is the editor callback deep-link. Scheme + host + path are - # pinned to EDITOR_CALLBACK; any query (e.g. ?windowId=N) is allowed and gets - # preserved by editor_callback_with_code. + # True when uri_str is the editor callback deep-link. Any query (e.g. ?windowId=N) is allowed + # and gets preserved by editor_callback_with_code. def editor_deep_link?(uri_str) - return false if uri_str.blank? - - u = URI.parse(uri_str.to_s) - e = URI.parse(EDITOR_CALLBACK) - EDITOR_SCHEMES.include?(u.scheme) && u.host == e.host && u.path == e.path - rescue URI::InvalidURIError - false + uri_str.present? && Levelcode::EditorCallback.current.match?(uri_str) end # --- Session-stashed editor context (OAuth round-trip) -------------------- diff --git a/app/services/levelcode/editor_callback.rb b/app/services/levelcode/editor_callback.rb new file mode 100644 index 00000000..43acbce1 --- /dev/null +++ b/app/services/levelcode/editor_callback.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Levelcode + # The one address a sign-in may hand its one-time code to: the editor's own deep link. + # + # It has three parts and only one of them varies. The host is the extension id + # (.) and the path is its auth route — both exact. The scheme is the editor + # BUILD's, its product urlProtocol, so it is a short list rather than a single value. + # + # Everything else is refused, because whatever passes gets a one-time code appended to it: a wider + # rule here is an open redirect that carries a credential. A query is allowed — the editor adds + # ?windowId=N so the callback reaches the window that asked — and is the caller's to preserve. + # + # Levelcode::WebController (the /ai flows) and the API's AuthController both ask this, so the two + # gates cannot disagree about what an editor is. + # + # An editor run from source has its own scheme, `levelcode-dev`: with the shipped one, macOS hands + # the callback to the installed app instead of the editor that asked. No server takes it unless + # told to: + # + # LEVELCODE_EXTRA_EDITOR_SCHEMES comma list of further schemes to accept, e.g. `levelcode-dev` + # + # Unset — production — the list is the shipped schemes and nothing else. Set it on the server a + # development editor signs in to, beside LEVELCODE_HOSTS (Levelcode::Hosts). When a development + # editor's sign-in ends on the account page in the browser and the editor hears nothing, this is + # the setting that is missing. + class EditorCallback + HOST = "levelcode.levelcode-ai" + PATH = "/auth/callback" + # `levelcode` is the shipped editor. `atom-plus-plus` is what a build from before the rename + # still sends. + SCHEMES = %w[levelcode atom-plus-plus].freeze + # What the setting may add: a LevelCode build's scheme, `levelcode-`, and nothing else. + # The code is appended to whatever this lets through and the browser is sent there, so a list + # that could be talked into `https` would post the code to a web host, and one that took + # `javascript` would run script on the account page. Saying what an entry must look like closes + # both without a list of schemes to forbid. + EXTRA_SCHEME = /\Alevelcode-[a-z0-9]+(?:[.-][a-z0-9]+)*\z/ + ENV_KEY = "LEVELCODE_EXTRA_EDITOR_SCHEMES" + + class << self + # The rule for this process, read from the environment once. + def current + @current ||= from_env.tap { |rule| warn_about(rule.ignored) } + end + + def from_env(env = ENV) + new(extra_schemes: env.fetch(ENV_KEY, "")) + end + + private + + # A typo in the setting is otherwise invisible: the entry is dropped, and the only symptom is + # the sign-in it was meant to allow going to the web account instead. + def warn_about(ignored) + return if ignored.empty? + + Rails.logger.warn("[levelcode] #{ENV_KEY}: ignoring #{ignored.map(&:inspect).join(', ')} — " \ + "an extra editor scheme looks like levelcode-dev") + end + end + + # schemes: every scheme accepted — the shipped ones, then the extra ones that were usable. + # ignored: entries of the setting that were not, as written. + attr_reader :schemes, :ignored + + # extra_schemes: a comma list — entries are trimmed and case-folded, blanks and repeats dropped. + # A shipped scheme named again is neither added nor reported: it is taken already. + def initialize(extra_schemes: "") + entries = extra_schemes.to_s.split(",").map(&:strip).reject { |entry| entry.empty? || SCHEMES.include?(entry.downcase) } + usable, ignored = entries.partition { |entry| entry.downcase.match?(EXTRA_SCHEME) } + @schemes = (SCHEMES + usable.map(&:downcase)).uniq.freeze + @ignored = ignored.freeze + freeze + end + + # Is `address` — a URI or a string — the editor's callback? Never raises: an address that does + # not parse is not the editor's. + def match?(address) + uri = URI.parse(address.to_s) + schemes.include?(uri.scheme) && uri.host == HOST && uri.path == PATH + rescue URI::InvalidURIError + false + end + end +end diff --git a/spec/requests/api/levelcode/v1/auth_spec.rb b/spec/requests/api/levelcode/v1/auth_spec.rb index a59080a4..45a97737 100644 --- a/spec/requests/api/levelcode/v1/auth_spec.rb +++ b/spec/requests/api/levelcode/v1/auth_spec.rb @@ -27,6 +27,10 @@ allow(Stripe::Customer).to receive(:retrieve).and_return(Stripe::Customer.construct_from(id: 'cus_test')) end + # A server that has been told nothing: the shipped editor schemes only, whatever the machine + # running the suite has exported (LEVELCODE_EXTRA_EDITOR_SCHEMES). Examples that opt in say so. + before { with_extra_editor_schemes('') } + let(:password) { 'password123' } let!(:user) { create(:user, email: 'editor@example.com', password: password) } let(:redirect_uri) { 'levelcode://levelcode.levelcode-ai/auth/callback' } @@ -79,6 +83,51 @@ def json expect(json.dig('error', 'code')).to eq('invalid_credentials') end + # WHICH addresses are the editor's, at this controller's own gate (it keeps its own copy of the + # rule Levelcode::WebController has). Anything else falls back to JSON: no redirect, no code. + { + 'levelcode://levelcode.levelcode-ai/auth/callback' => 'the shipped editor', + 'atom-plus-plus://levelcode.levelcode-ai/auth/callback' => 'a build from before the rename' + }.each do |address, whose| + it "302s the code to #{whose} — #{address}" do + post '/api/levelcode/v1/auth/login', + params: { email: user.email, password: password, redirect_uri: "#{address}?windowId=2", code_challenge: 'chal' } + + expect(response).to have_http_status(:found) + location = response.headers['Location'] + expect(location).to start_with("#{address}?windowId=2&code=") + expect(Rack::Utils.parse_query(URI.parse(location).query)['code']).to be_present + end + end + + { + 'levelcode-dev://levelcode.levelcode-ai/auth/callback' => 'a scheme that is not on the list', + 'levelcode://evil.example/auth/callback' => 'the right scheme on another host', + 'levelcode://levelcode.levelcode-ai/elsewhere' => 'the right host on another path' + }.each do |address, what| + it "does not redirect to #{what} — #{address}" do + post '/api/levelcode/v1/auth/login', + params: { email: user.email, password: password, redirect_uri: address, code_challenge: 'chal' } + + expect(response).to have_http_status(:ok) + expect(response.headers['Location']).to be_nil + expect(json['access']).to be_present + end + end + + it '302s the code to a development editor once the server is told to take its scheme' do + with_extra_editor_schemes('levelcode-dev') + + post '/api/levelcode/v1/auth/login', + params: { email: user.email, password: password, code_challenge: 'chal', + redirect_uri: 'levelcode-dev://levelcode.levelcode-ai/auth/callback?windowId=2' } + + expect(response).to have_http_status(:found) + location = response.headers['Location'] + expect(location).to start_with('levelcode-dev://levelcode.levelcode-ai/auth/callback?windowId=2&code=') + expect(Rack::Utils.parse_query(URI.parse(location).query)['code']).to be_present + end + it 'refuses a non-https/non-editor redirect_uri (no open redirect)' do post '/api/levelcode/v1/auth/login', params: { email: user.email, password: password, redirect_uri: 'http://evil.example.com' } diff --git a/spec/requests/levelcode/web_spec.rb b/spec/requests/levelcode/web_spec.rb index 87a0590d..dfe0eeee 100644 --- a/spec/requests/levelcode/web_spec.rb +++ b/spec/requests/levelcode/web_spec.rb @@ -20,6 +20,11 @@ host! 'www.example.com' end + # A server that has been told nothing: the shipped editor schemes only. Said outright, so the + # machine running the suite cannot say otherwise — a developer may well have + # LEVELCODE_EXTRA_EDITOR_SCHEMES exported for their own server. Examples that opt in say so. + before { with_extra_editor_schemes('') } + let(:editor_uri) { 'levelcode://levelcode.levelcode-ai/auth/callback' } def json = JSON.parse(response.body) @@ -224,6 +229,48 @@ def json = JSON.parse(response.body) expect(json).to eq('redirect' => '/ai/account') end + # An address that is not the editor's is not refused here — it is not an editor sign-in at all. + # The browser is signed in to the web account and sent to it, and the editor that asked hears + # nothing. That is what a developer sees when their editor's scheme is not one this server takes. + it 'treats an editor-shaped address on a scheme it does not take as a web sign-in' do + allow(Levelcode::EmailCode).to receive(:verify).and_return(true) + expect(Levelcode::OneTimeCode).not_to receive(:issue) + + post '/ai/auth/verify', + params: { email: 'dev@example.com', code: '123456', + redirect_uri: 'levelcode-dev://levelcode.levelcode-ai/auth/callback', code_challenge: 'chal' } + + expect(response).to have_http_status(:ok) + expect(json).to eq('redirect' => '/ai/account') + end + + # …and on a server told to take it, the same request is an editor sign-in. + it 'hands a development editor its code once the server is told to take its scheme' do + with_extra_editor_schemes('levelcode-dev') + allow(Levelcode::EmailCode).to receive(:verify).and_return(true) + allow(Levelcode::OneTimeCode).to receive(:issue).and_return('one-time-code') + + # Double-encoded, as the editor's ?windowId tail arrives through the login page. + post '/ai/auth/verify', + params: { email: 'dev@example.com', code: '123456', + redirect_uri: 'levelcode-dev://levelcode.levelcode-ai/auth/callback%3FwindowId%3D1', code_challenge: 'chal' } + + expect(response).to have_http_status(:ok) + expect(json).to eq('redirect' => 'levelcode-dev://levelcode.levelcode-ai/auth/callback?windowId=1&code=one-time-code') + end + + it 'still fails closed for a development editor with no code_challenge' do + with_extra_editor_schemes('levelcode-dev') + allow(Levelcode::EmailCode).to receive(:verify).and_return(true) + expect(Levelcode::OneTimeCode).not_to receive(:issue) + + post '/ai/auth/verify', + params: { email: 'dev@example.com', code: '123456', redirect_uri: 'levelcode-dev://levelcode.levelcode-ai/auth/callback' } + + expect(response).to have_http_status(:unprocessable_content) + expect(json.dig('error', 'code')).to eq('link_expired') + end + it 'rejects an invalid code' do allow(Levelcode::EmailCode).to receive(:verify).and_return(false) post '/ai/auth/verify', params: { email: 'nope@example.com', code: '000000' } @@ -435,6 +482,88 @@ def sign_in_browser(code) expect(response).to have_http_status(:unprocessable_content) end + # WHICH addresses are the editor's. The scheme is the editor build's identity; the host is the + # extension id and the path its auth route. One example per address, so that the list cannot + # grow or shrink without one of these changing. + { + 'levelcode://levelcode.levelcode-ai/auth/callback' => 'the shipped editor', + 'atom-plus-plus://levelcode.levelcode-ai/auth/callback' => 'a build from before the rename' + }.each_with_index do |(address, whose), i| + it "hands the code to #{whose} — #{address}" do + sign_in_browser("sess-takes-#{i}") + allow(Levelcode::OneTimeCode).to receive(:issue).with(user, 'chal').and_return('bound-code') + + post '/ai/authorize_editor', params: { redirect_uri: "#{address}?windowId=2", code_challenge: 'chal' } + + expect(response).to have_http_status(:ok) + expect(json['redirect']).to eq("#{address}?windowId=2&code=bound-code") + end + end + + { + 'levelcode-dev://levelcode.levelcode-ai/auth/callback' => 'a scheme that is not on the list', + 'levelcode://evil.example/auth/callback' => 'the right scheme on another host', + 'levelcode://levelcode.levelcode-ai/elsewhere' => 'the right host on another path', + 'https://levelcode.levelcode-ai/auth/callback' => 'the right host and path over https', + 'levelcode:levelcode.levelcode-ai/auth/callback' => 'an address with no host at all' + }.each_with_index do |(address, what), i| + it "mints nothing for #{what} — #{address}" do + sign_in_browser("sess-refuses-#{i}") + expect(Levelcode::OneTimeCode).not_to receive(:issue) + + post '/ai/authorize_editor', params: { redirect_uri: address, code_challenge: 'chal' } + + expect(response).to have_http_status(:unprocessable_content) + expect(json.dig('error', 'code')).to eq('invalid_request') + end + end + + context 'on a server told to take a development editor (LEVELCODE_EXTRA_EDITOR_SCHEMES)' do + before { with_extra_editor_schemes('levelcode-dev') } + + it 'hands the code to the development editor' do + sign_in_browser('sess-dev-a') + allow(Levelcode::OneTimeCode).to receive(:issue).with(user, 'chal').and_return('bound-code') + + post '/ai/authorize_editor', + params: { redirect_uri: 'levelcode-dev://levelcode.levelcode-ai/auth/callback?windowId=2', code_challenge: 'chal' } + + expect(response).to have_http_status(:ok) + expect(json['redirect']).to eq('levelcode-dev://levelcode.levelcode-ai/auth/callback?windowId=2&code=bound-code') + end + + it 'goes on handing it to the shipped editor — the list grew, it was not replaced' do + sign_in_browser('sess-dev-b') + allow(Levelcode::OneTimeCode).to receive(:issue).with(user, 'chal').and_return('bound-code') + + post '/ai/authorize_editor', params: { redirect_uri: editor_uri, code_challenge: 'chal' } + + expect(response).to have_http_status(:ok) + expect(json['redirect']).to eq("#{editor_uri}?code=bound-code") + end + + it 'mints nothing for the development scheme on another host' do + sign_in_browser('sess-dev-c') + expect(Levelcode::OneTimeCode).not_to receive(:issue) + + post '/ai/authorize_editor', params: { redirect_uri: 'levelcode-dev://evil.example/auth/callback', code_challenge: 'chal' } + + expect(response).to have_http_status(:unprocessable_content) + end + end + + # A list that could be talked into a web scheme would post the code to a web host; the setting + # cannot add one, whatever it says. + it 'mints nothing for https even on a server whose setting names it' do + with_extra_editor_schemes('https, levelcode-dev') + sign_in_browser('sess-https') + expect(Levelcode::OneTimeCode).not_to receive(:issue) + + post '/ai/authorize_editor', params: { redirect_uri: 'https://levelcode.levelcode-ai/auth/callback', code_challenge: 'chal' } + + expect(response).to have_http_status(:unprocessable_content) + end + it 'does not mint for an anonymous request' do post '/ai/authorize_editor', params: { redirect_uri: editor_uri, code_challenge: 'chal' }, as: :json expect(response).not_to have_http_status(:ok) diff --git a/spec/services/levelcode/editor_callback_spec.rb b/spec/services/levelcode/editor_callback_spec.rb new file mode 100644 index 00000000..55fc4d8c --- /dev/null +++ b/spec/services/levelcode/editor_callback_spec.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Levelcode::EditorCallback do + subject(:callback) { described_class.new } + + let(:address) { "levelcode://levelcode.levelcode-ai/auth/callback" } + + describe "#match?" do + it "is true for the editor's deep link, with or without a query" do + expect(callback.match?(address)).to be(true) + expect(callback.match?("#{address}?windowId=3")).to be(true) + end + + it "takes a URI as readily as a string" do + expect(callback.match?(URI.parse("#{address}?windowId=3"))).to be(true) + end + + it "is true for each scheme on the list, and for no other" do + expect(callback.schemes).to eq(%w[levelcode atom-plus-plus]) + expect(callback.match?("atom-plus-plus://levelcode.levelcode-ai/auth/callback")).to be(true) + expect(callback.match?("levelcode-dev://levelcode.levelcode-ai/auth/callback")).to be(false) + expect(callback.match?("https://levelcode.levelcode-ai/auth/callback")).to be(false) + end + + it "reads the scheme as a URL does — its case is not part of it" do + expect(callback.match?("LevelCode://levelcode.levelcode-ai/auth/callback")).to be(true) + end + + it "compares the host and the path exactly" do + expect(callback.match?("levelcode://evil.example/auth/callback")).to be(false) + expect(callback.match?("levelcode://levelcode.levelcode-ai.evil.example/auth/callback")).to be(false) + expect(callback.match?("levelcode://levelcode.levelcode-ai/auth/callback/extra")).to be(false) + expect(callback.match?("levelcode://levelcode.levelcode-ai/auth")).to be(false) + expect(callback.match?("levelcode://levelcode.levelcode-ai")).to be(false) + end + + it "is false for an address with no host — the extension id sitting in the path proves nothing" do + expect(callback.match?("levelcode:levelcode.levelcode-ai/auth/callback")).to be(false) + expect(callback.match?("levelcode:/auth/callback")).to be(false) + end + + it "is false — never an exception — for what is not an address at all" do + expect(callback.match?("levelcode://not a url")).to be(false) + expect(callback.match?("")).to be(false) + expect(callback.match?(nil)).to be(false) + end + end + + # A development editor has its own scheme, and no server takes it unless told to. + describe "extra schemes" do + let(:dev_address) { "levelcode-dev://levelcode.levelcode-ai/auth/callback" } + + it "are none by default — the shipped schemes are the whole list" do + expect(callback.schemes).to eq(%w[levelcode atom-plus-plus]) + expect(callback.ignored).to eq([]) + expect(callback.match?(dev_address)).to be(false) + end + + it "are taken from a comma list — trimmed, case-folded, blanks and repeats dropped" do + rule = described_class.new(extra_schemes: " levelcode-dev, LevelCode-Insiders ,,levelcode-dev,") + expect(rule.schemes).to eq(%w[levelcode atom-plus-plus levelcode-dev levelcode-insiders]) + expect(rule.ignored).to eq([]) + end + + it "are accepted on top of the shipped schemes, never in place of them" do + rule = described_class.new(extra_schemes: "levelcode-dev") + expect(rule.match?("#{dev_address}?windowId=1")).to be(true) + expect(rule.match?(address)).to be(true) + expect(rule.match?("atom-plus-plus://levelcode.levelcode-ai/auth/callback")).to be(true) + end + + it "leave the host and the path as exact as they were" do + rule = described_class.new(extra_schemes: "levelcode-dev") + expect(rule.match?("levelcode-dev://evil.example/auth/callback")).to be(false) + expect(rule.match?("levelcode-dev://levelcode.levelcode-ai/elsewhere")).to be(false) + end + + # The code is appended to whatever passes and the browser is sent there. `https` would post it + # to a web host; `javascript` would run script on the account page. + it "must be a LevelCode build's scheme — nothing a browser resolves itself can be added" do + unusable = %w[https http javascript data file ws ftp vbscript intent about] + rule = described_class.new(extra_schemes: unusable.join(",")) + + expect(rule.schemes).to eq(%w[levelcode atom-plus-plus]) + expect(rule.ignored).to eq(unusable) + unusable.each do |scheme| + expect(rule.match?("#{scheme}://levelcode.levelcode-ai/auth/callback")).to be(false), scheme + end + end + + it "must be spelled as one: levelcode-" do + misspelt = %w[levelcode_dev levelcode- -dev dev levelcode-dev:// levelcode--dev levelcode-dev- xlevelcode-dev levelcode-dév] + rule = described_class.new(extra_schemes: misspelt.join(",")) + + expect(rule.schemes).to eq(%w[levelcode atom-plus-plus]) + expect(rule.ignored).to eq(misspelt) + end + + it "report an unusable entry as it was written, beside the ones that were taken" do + rule = described_class.new(extra_schemes: "levelcode-dev, HTTPS") + expect(rule.schemes).to eq(%w[levelcode atom-plus-plus levelcode-dev]) + expect(rule.ignored).to eq(%w[HTTPS]) + end + + it "do not report a shipped scheme named again — it is taken already" do + rule = described_class.new(extra_schemes: "levelcode, Atom-Plus-Plus, levelcode-dev") + expect(rule.schemes).to eq(%w[levelcode atom-plus-plus levelcode-dev]) + expect(rule.ignored).to eq([]) + end + + it "are none for an empty or missing list" do + expect(described_class.new(extra_schemes: "").schemes).to eq(%w[levelcode atom-plus-plus]) + expect(described_class.new(extra_schemes: nil).schemes).to eq(%w[levelcode atom-plus-plus]) + end + end + + # ENV is passed in rather than read, so "the list really comes from the environment" is an + # assertion about behaviour (as in Levelcode::Hosts). + describe ".from_env" do + it "reads the extra schemes from the environment it is given" do + rule = described_class.from_env("LEVELCODE_EXTRA_EDITOR_SCHEMES" => "levelcode-dev") + expect(rule.schemes).to eq(%w[levelcode atom-plus-plus levelcode-dev]) + end + + it "takes no extra scheme when the setting is absent — what production runs with" do + expect(described_class.from_env({}).schemes).to eq(%w[levelcode atom-plus-plus]) + end + end + + describe ".current" do + # Its own memo, so the process-wide rule is never disturbed. + let(:fresh) { Class.new(described_class) } + + it "is built from the environment, once" do + expect(fresh).to receive(:from_env).once.and_call_original + expect(fresh.current).to equal(fresh.current) + expect(fresh.current).to be_a(described_class) + end + + it "says so in the log, once, when the setting names something it will not take" do + allow(fresh).to receive(:from_env).and_return(described_class.new(extra_schemes: "levelcode-dev, https")) + expect(Rails.logger).to receive(:warn).once.with(/LEVELCODE_EXTRA_EDITOR_SCHEMES: ignoring "https"/) + + 2.times { fresh.current } + end + + it "says nothing when every entry was taken" do + allow(fresh).to receive(:from_env).and_return(described_class.new(extra_schemes: "levelcode-dev")) + expect(Rails.logger).not_to receive(:warn) + + fresh.current + end + end + + it "is immutable" do + rule = described_class.new(extra_schemes: "levelcode-dev, https") + expect(rule).to be_frozen + expect(rule.schemes).to be_frozen + expect(rule.ignored).to be_frozen + end +end diff --git a/spec/support/auth_helpers.rb b/spec/support/auth_helpers.rb index 5497c299..5b9ea736 100644 --- a/spec/support/auth_helpers.rb +++ b/spec/support/auth_helpers.rb @@ -4,4 +4,10 @@ def auth_headers(user) token = Warden::JWTAuth::UserEncoder.new.call(user, :user, nil).first { 'Authorization' => "Bearer #{token}" } end + + # Run an example on a server told to take further editor schemes — what + # LEVELCODE_EXTRA_EDITOR_SCHEMES would set (Levelcode::EditorCallback). + def with_extra_editor_schemes(list) + allow(Levelcode::EditorCallback).to receive(:current).and_return(Levelcode::EditorCallback.new(extra_schemes: list)) + end end