Prd/components/backend/test/test_operator_auth.rb
Lucy Doupalů be9f14ce34 Helpdesk - operator console + patched GrapheneOS Dialer for call handling
A small helpdesk system: an office Pixel running a patched GrapheneOS Dialer
answers technician calls, records both call legs as separate channels, and a
Ruby backend transcribes them through Whisper and files an AI summary against
the caller.

Squashed to a single commit for sharing. No credentials are included; secrets
live outside the repo in /etc/helpdesk/env on the server or a gitignored
.claude/env.local locally. See .claude/env.local.example for the shape.

Start at README.md, then docs/architecture.md.
2026-07-27 18:50:32 +02:00

100 lines
6.2 KiB
Ruby

# 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)