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.
100 lines
6.2 KiB
Ruby
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)
|