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.
174 lines
9.2 KiB
Ruby
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)
|