Prd/components/backend/test/test_cert_watch.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

174 lines
9.2 KiB
Ruby

# frozen_string_literal: true
# Zero-dependency offline tests for the certificate expiry watch (no gems, no network).
# Real certificates are generated in-process with OpenSSL, so the PEM and p12 parsing is exercised for
# real rather than mocked - that parsing is the part most likely to break silently.
# Run: ruby components/backend/test/test_cert_watch.rb
require_relative "../lib/helpdesk/cert_watch"
require "tmpdir"
$pass = 0; $fail = 0
def ok(d); $pass += 1; puts " ok #{d}"; end
def bad(d, got = nil); $fail += 1; puts " FAIL #{d}#{got.nil? ? '' : " (got: #{got.inspect})"}"; end
def eq(a, b, d); a == b ? ok(d) : bad(d, a); end
def truthy(x, d); x ? ok(d) : bad(d, x); end
def falsy(x, d); x ? bad(d, x) : ok(d); end
CW = Helpdesk::CertWatch
NOW = Time.utc(2026, 7, 27, 12, 0, 0)
# --- a real self-signed cert expiring `days` from NOW -------------------------------------------------
def make_cert(days)
key = OpenSSL::PKey::RSA.new(2048)
c = OpenSSL::X509::Certificate.new
c.version = 2
c.serial = 1
c.subject = c.issuer = OpenSSL::X509::Name.parse("/CN=test.example")
c.public_key = key.public_key
c.not_before = NOW - 86_400
c.not_after = NOW + (days * 86_400)
c.sign(key, OpenSSL::Digest.new("SHA256"))
[c, key]
end
puts "severity ladder"
eq(CW.severity(90), "ok", "90 days out is fine")
eq(CW.severity(31), "ok", "just outside the window is still fine")
eq(CW.severity(30), "warning", "exactly at the threshold warns")
eq(CW.severity(15), "warning", "15 days is a warning")
eq(CW.severity(14), "critical", "14 days is critical")
eq(CW.severity(0), "critical", "expiring today is critical, not expired")
eq(CW.severity(-1), "expired", "past the date is expired")
eq(CW.severity(-400),"expired", "long past is still expired")
puts "severity respects a custom warn window"
eq(CW.severity(20, warn_days: 10), "ok", "outside a tighter window")
eq(CW.severity(9, warn_days: 10), "critical", "critical never overtakes a warn window below 14")
eq(CW.severity(60, warn_days: 90), "warning", "a wider window warns earlier")
puts "days_between rounds DOWN so 'in 0 days' means today"
eq(CW.days_between(NOW, NOW + (86_400 * 5)), 5, "exactly 5 days")
eq(CW.days_between(NOW, NOW + (86_400 * 5) + 100),5, "5 days and a bit is still 5")
eq(CW.days_between(NOW, NOW + 3600), 0, "an hour away is 0 days, not 1")
eq(CW.days_between(NOW, NOW - 100), -1, "just past is -1")
puts "PEM bundle: earliest expiry in the chain wins"
Dir.mktmpdir do |dir|
near, = make_cert(10)
far, = make_cert(500)
bundle = File.join(dir, "ca-combined.crt")
File.write(bundle, far.to_pem + near.to_pem) # deliberately NOT in expiry order
eq(CW.bundle_expiry(bundle).to_i, near.not_after.to_i, "picks the soonest of several certs")
one = File.join(dir, "one.crt"); File.write(one, far.to_pem)
eq(CW.bundle_expiry(one).to_i, far.not_after.to_i, "single-cert bundle works")
eq(CW.bundle_expiry(File.join(dir, "nope.crt")), nil, "missing file -> nil, no raise")
eq(CW.bundle_expiry(nil), nil, "nil path -> nil")
eq(CW.bundle_expiry(""), nil, "empty path -> nil")
junk = File.join(dir, "junk.crt"); File.write(junk, "not a certificate at all")
eq(CW.bundle_expiry(junk), nil, "garbage file -> nil, no raise")
end
puts "p12: parsed with the password the backend already holds"
Dir.mktmpdir do |dir|
cert, key = make_cert(42)
p12 = File.join(dir, "client.p12")
File.binwrite(p12, OpenSSL::PKCS12.create("s3cret", "client", key, cert).to_der)
eq(CW.p12_expiry(p12, "s3cret").to_i, cert.not_after.to_i, "correct password reads the expiry")
eq(CW.p12_expiry(p12, "wrong"), nil, "wrong password -> nil, no raise")
eq(CW.p12_expiry(p12, nil), nil, "nil password -> nil")
eq(CW.p12_expiry(File.join(dir, "nope.p12"), "x"), nil, "missing file -> nil")
# The real vmin case: a long-lived leaf signed by a CA that runs out much sooner. Reading the leaf
# alone would report years of safety for something that stops working first.
long_leaf, lkey = make_cert(3600)
short_ca, _ = make_cert(60)
chained = File.join(dir, "chained.p12")
File.binwrite(chained, OpenSSL::PKCS12.create("p", "c", lkey, long_leaf, [short_ca]).to_der)
eq(CW.p12_expiry(chained, "p").to_i, short_ca.not_after.to_i,
"a leaf outliving its CA reports the CA date, not the leaf's")
falsy(CW.p12_expiry(chained, "p").to_i == long_leaf.not_after.to_i,
"specifically NOT the optimistic leaf date")
end
puts "served_expiry fails soft when nothing is listening"
eq(CW.served_expiry("127.0.0.1:1", timeout: 1), nil, "connection refused -> nil, no raise")
eq(CW.served_expiry(""), nil, "empty target -> nil")
eq(CW.served_expiry(nil), nil, "nil target -> nil")
puts "check(): assembles, sorts soonest-first, and omits unreadable sources"
Dir.mktmpdir do |dir|
soon, = make_cert(5)
late, = make_cert(200)
ca = File.join(dir, "ca.crt"); File.write(ca, late.to_pem)
cert, key = make_cert(5)
p12 = File.join(dir, "c.p12"); File.binwrite(p12, OpenSSL::PKCS12.create("p", "c", key, cert).to_der)
r = CW.check(now: NOW, ca_path: ca, p12_path: p12, p12_pass: "p", tls: nil)
eq(r.length, 2, "two readable sources -> two entries (the unset TLS probe is omitted)")
eq(r.map { |e| e["name"] }, ["Whisper client certificate", "Operator CA (vmin)"], "sorted soonest first")
eq(r[0]["days_left"], 5, "days_left computed against the injected clock")
eq(r[0]["severity"], "critical", "5 days is critical")
eq(r[1]["severity"], "ok", "200 days is ok and still reported, so the UI can decide")
truthy(r[0]["what_breaks"].include?("transcription"), "carries what actually breaks")
truthy(r[0]["expires_at"].end_with?("Z"), "expires_at is UTC ISO8601")
eq(CW.check(now: NOW), [], "nothing configured -> empty, which is the local-dev case")
eq(CW.check(now: NOW, ca_path: "/nope", p12_path: "/nope"), [], "unreadable sources are omitted, not faked ok")
end
puts "presented_entry: the operator's OWN cert, which we hold no copy of"
e = CW.presented_entry("9", "Dec 15 12:00:00 2027 GMT", warn_days: 30)
eq(e["days_left"], 9, "days come straight from nginx's $ssl_client_v_remain")
eq(e["severity"], "critical", "9 days is critical")
eq(e["expires_at"], "2027-12-15T12:00:00Z", "nginx's date format is normalised to ISO8601")
eq(CW.normalize_nginx_date("Sep 9 08:34:27 2030 GMT"), "2030-09-09T08:34:27Z",
"openssl space-pads a single-digit day; %e handles it")
# Regression: Time.parse ignores the GMT suffix and reads the value as LOCAL time, shifting the date by
# the server's UTC offset. Prague is +1/+2, so this silently reported the wrong hour.
eq(CW.normalize_nginx_date("Dec 15 00:30:00 2027 GMT"), "2027-12-15T00:30:00Z",
"the GMT suffix is honoured, not reinterpreted as local time")
eq(e["name"], "Your operator certificate", "named so an operator knows it is theirs, not the server's")
eq(CW.presented_entry("400", "Dec 15 12:00:00 2027 GMT")["severity"], "ok", "plenty of time -> ok")
eq(CW.presented_entry("-2", "Dec 15 12:00:00 2020 GMT")["severity"], "expired", "negative -> expired")
eq(CW.presented_entry(nil, nil), nil, "no headers -> nil (local dev, or nginx not updated)")
eq(CW.presented_entry("", ""), nil, "empty headers -> nil, not a bogus entry")
eq(CW.presented_entry(" ", nil), nil, "whitespace-only -> nil")
eq(CW.presented_entry("not-a-number", nil), nil, "junk in the header -> nil, never a raise")
eq(CW.presented_entry("5", "total nonsense")["expires_at"], "total nonsense",
"an unparseable date is passed through rather than dropping the whole warning")
eq(CW.presented_entry("5", nil)["expires_at"], nil, "missing date still yields a usable warning")
puts "config_from_env / configured?"
eq(CW.config_from_env({})[:warn_days], 30, "default warn window is 30 days")
eq(CW.config_from_env({ "HELPDESK_CERT_WARN_DAYS" => "7" })[:warn_days], 7, "warn window overridable")
eq(CW.config_from_env({ "WHISPER_MTLS_P12" => "/x.p12" })[:p12_path], "/x.p12", "reuses the Whisper p12 already in env")
falsy(CW.configured?(CW.config_from_env({})), "no env -> not configured -> /status keeps its old shape")
truthy(CW.configured?(CW.config_from_env({ "HELPDESK_CERT_WATCH_CA" => "/ca" })), "any one source enables it")
truthy(CW.configured?(CW.config_from_env({ "HELPDESK_CERT_WATCH_TLS" => "h:443" })), "the TLS probe alone enables it")
puts "cache never blocks the request thread"
t = 0.0
calls = 0
cache = CW::Cache.new(ttl: 100, clock: -> { t }) { calls += 1; sleep 0.05; [{ "n" => calls }] }
eq(cache.value, [], "first read returns empty immediately rather than waiting on the probe")
sleep 0.3
eq(cache.value, [{ "n" => 1 }], "the background refresh lands and is served afterwards")
eq(cache.value, [{ "n" => 1 }], "still fresh -> no refetch")
eq(calls, 1, "fresh value is not re-probed")
t = 500.0
cache.value # stale: triggers a background refresh, still returns the old value at once
sleep 0.3
eq(cache.value, [{ "n" => 2 }], "past the TTL it refreshes")
truthy(calls == 2, "exactly one refetch after expiry")
puts "a failing probe does not wedge the cache"
boom = CW::Cache.new(ttl: 0, clock: -> { 0.0 }) { raise "probe exploded" }
eq(boom.value, [], "raising fetch -> empty, no exception escapes")
sleep 0.2
eq(boom.value, [], "and it can be retried rather than being stuck refreshing")
puts
puts "#{$pass} passed, #{$fail} failed"
exit($fail.zero? ? 0 : 1)