# frozen_string_literal: true # Zero-dependency offline tests for the operator identity rules (no gems, no network, no server boot). # Covers the two halves of "who is an operator": # 1. Server.cert_cn / Server.operator_allowed? - the HELPDESK_OPERATOR_CNS allowlist. nginx only proves # a cert was signed by ca.vmin.cz; this decides which holders may use the console, and is also the # only revocation we have (no CRL is configured). # 2. the `by` attribution those identities leave on operator-sourced ledger entries. # Run: ruby components/backend/test/test_operator_auth.rb require_relative "../server" require_relative "../lib/helpdesk/domain" $pass = 0; $fail = 0 def ok(desc); $pass += 1; puts " ok #{desc}"; end def bad(desc, got = nil); $fail += 1; puts " FAIL #{desc}#{got.nil? ? '' : " (got: #{got.inspect})"}"; end def eq(a, b, desc); a == b ? ok(desc) : bad(desc, a); end def truthy(x, desc); x ? ok(desc) : bad(desc, x); end def falsy(x, desc); x ? bad(desc, x) : ok(desc); end S = Helpdesk::Server puts "cert_cn: pulling the CN out of a subject DN" eq(S.cert_cn("CN=lucy,O=Virtualmaster.com,C=CZ"), "lucy", "RFC2253 form (nginx >= 1.11.6)") eq(S.cert_cn("/C=CZ/O=Virtualmaster.com/CN=lucy"), "lucy", "legacy slash form") eq(S.cert_cn("CN=Jan Novak,C=CZ"), "Jan Novak", "a CN containing a space is kept whole") eq(S.cert_cn("O=Acme,CN=lucy"), "lucy", "CN found when it is not the first RDN") eq(S.cert_cn("cn=lucy,C=CZ"), "lucy", "the RDN key is matched case-insensitively") eq(S.cert_cn("CN= lucy ,C=CZ"), "lucy", "surrounding whitespace trimmed") eq(S.cert_cn("O=Acme,C=CZ"), nil, "no CN in the DN -> nil") eq(S.cert_cn(""), nil, "empty DN -> nil") eq(S.cert_cn(nil), nil, "nil DN -> nil") eq(S.cert_cn("O=CN=spoofed,CN=real"), "real", "a CN= inside another attribute's VALUE is not mistaken for the CN (matching is anchored to RDN starts)") # Known limit: RFC2253 escapes a comma inside a value as "\," and this splits on it anyway, so such a CN # comes back truncated. That fails CLOSED (a truncated CN matches no allowlist entry), and operator CNs # here are plain names, so it is documented rather than parsed properly. eq(S.cert_cn("CN=Novak\\, Jan,O=Acme"), "Novak\\", "an escaped comma truncates the CN (fails closed)") puts "operator_allowed?: an EMPTY allowlist keeps the pre-allowlist behaviour" truthy(S.operator_allowed?("CN=anyone,C=CZ", []), "any non-empty DN passes when no allowlist is set") truthy(S.operator_allowed?("O=Acme,C=CZ", []), "a DN with no CN still passes when no allowlist is set") falsy(S.operator_allowed?("", []), "an empty DN is refused even with no allowlist") falsy(S.operator_allowed?(nil, []), "a nil DN is refused even with no allowlist") puts "operator_allowed?: a CONFIGURED allowlist pins which cert holders are operators" AL = %w[lucy petr].freeze # as built by the constant: split, stripped, downcased truthy(S.operator_allowed?("CN=lucy,O=Virtualmaster.com,C=CZ", AL), "a listed CN is allowed") truthy(S.operator_allowed?("/C=CZ/CN=petr", AL), "a listed CN in the legacy DN form is allowed") truthy(S.operator_allowed?("CN=LUCY,C=CZ", AL), "matching is case-insensitive") falsy(S.operator_allowed?("CN=mallory,C=CZ", AL), "an unlisted CN is refused THOUGH THE CERT IS CA-VALID") falsy(S.operator_allowed?("O=Acme,C=CZ", AL), "a DN with no CN is refused when an allowlist is set") falsy(S.operator_allowed?("", AL), "an empty DN is refused") falsy(S.operator_allowed?("CN=lucy2,C=CZ", AL), "no prefix match: lucy2 is not lucy") falsy(S.operator_allowed?("CN=luc,C=CZ", AL), "no substring match: luc is not lucy") puts "the allowlist constant parses the env var the way the docs promise" parse = ->(s) { s.split(",").map { |x| x.strip.downcase }.reject(&:empty?) } eq(parse.call("lucy,petr"), %w[lucy petr], "comma separated") eq(parse.call(" lucy , Petr "), %w[lucy petr], "whitespace trimmed and case folded") eq(parse.call("lucy,,petr,"), %w[lucy petr], "empty fields dropped") eq(parse.call(""), [], "unset -> empty -> allow any verified cert") puts "attribution: operator-sourced ledger entries record WHO decided" s = Helpdesk::Service.new(Helpdesk::Store.new) pid = s.add_person(name: "Attrib", operator: "vodafone") s.add_resolution(person_id: pid, question: "Přístup do věže?", decision: "no", by: "lucy") e = s.store.people[pid][:resolutions_ledger].last eq(e["by"], "lucy", "add_resolution records `by`") eq(e["source"], "operator", "add_resolution still tags source=operator") s.edit_resolution(person_id: pid, entry_id: e["id"], question: "Přístup do věže?", decision: "yes", by: "petr") e2 = s.store.people[pid][:resolutions_ledger].last eq(e2["by"], "petr", "edit_resolution records the editing operator, not the original one") eq(s.store.people[pid][:resolutions_ledger].find { |x| x["id"] == e["id"] }["by"], "lucy", "the superseded entry keeps its original `by` (append-only history stays intact)") s.add_resolution(person_id: pid, question: "Jiná otázka?", decision: "no") eq(s.store.people[pid][:resolutions_ledger].last.key?("by"), false, "`by` is omitted entirely when the identity is unknown (local dev, auth off)") s.add_resolution(person_id: pid, question: "Třetí otázka?", decision: "no", by: " ") eq(s.store.people[pid][:resolutions_ledger].last.key?("by"), false, "a blank `by` is treated as unknown") puts "attribution: resolving a contradiction by OVERRIDE also records who" s2 = Helpdesk::Service.new(Helpdesk::Store.new) pid2 = s2.add_person(name: "Conflict", operator: "vodafone") led2 = (s2.store.people[pid2][:resolutions_ledger] ||= []) led2 << { "id" => "led1", "question" => "Přístup?", "decision" => "yes", "source" => "call", "first_at" => "2026-07-01T09:00:00Z", "last_at" => "2026-07-01T09:00:00Z", "quote" => "ano", "contradiction" => true } s2.resolve_contradiction(person_id: pid2, entry_id: "led1", decision: "no", by: "lucy") neu = s2.store.people[pid2][:resolutions_ledger].last eq(neu["by"], "lucy", "resolve_contradiction override records `by`") eq(neu["decision"], "no", "resolve_contradiction override carries the new decision") truthy(neu["acknowledged"], "resolve_contradiction override is acknowledged") eq(neu["source"], "operator", "resolve_contradiction override is operator-sourced") puts puts "#{$pass} passed, #{$fail} failed" exit($fail.zero? ? 0 : 1)