# frozen_string_literal: true # Webhook simulator — stands in for the phone, driving the device API over real HTTP with correct # device auth: the happy path plus the awkward cases (out-of-order webhooks, an orphan upload, a # rejected signature). Point it at a server with HELPDESK_URL. The quickest way to watch a whole call # go through without a phone. Run the server first, then: ruby components/backend/sim/simulator.rb require "net/http" require "json" require "openssl" require "securerandom" require "time" BASE = ENV.fetch("HELPDESK_URL", "http://127.0.0.1:4000") TOKEN = ENV.fetch("HELPDESK_DEVICE_TOKEN", "dev-device-token") SECRET = ENV.fetch("HELPDESK_DEVICE_SECRET", "dev-device-secret") $pass = 0; $fail = 0 def ok(d); $pass += 1; puts " ok #{d}"; end def bad(d, g = nil); $fail += 1; puts " FAIL #{d}#{g.nil? ? '' : " (#{g.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 signed_headers(body) ts = Time.now.to_i; nonce = SecureRandom.hex(8) { "Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json", "X-Request-Ts" => ts.to_s, "X-Request-Nonce" => nonce, "X-Signature" => OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{ts}.#{nonce}.#{body}") } end def post(path, obj, headers: nil) body = JSON.generate(obj) http_req(Net::HTTP::Post, path, body, headers || signed_headers(body)) end def put_rec(uuid, call_id, bytes = "AUDIO") http_req(Net::HTTP::Put, "/api/v1/recordings/#{uuid}", bytes, signed_headers(bytes).merge("X-Call-Id" => call_id, "Content-Type" => "application/octet-stream")) end def get(path) uri = URI("#{BASE}#{path}") res = Net::HTTP.get_response(uri) [res.code.to_i, safe_json(res.body)] end # a device-authed GET (signed, no body). The device channels (heartbeat, command long-poll) are GETs # with no Content-Length; this is what exercises the read() content-length path they hit. def signed_get(path) uri = URI("#{BASE}#{path}") req = Net::HTTP::Get.new(uri); signed_headers("").each { |k, v| req[k] = v } res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |h| h.request(req) } [res.code.to_i, safe_json(res.body)] end def http_req(klass, path, body, headers) uri = URI("#{BASE}#{path}") req = klass.new(uri); headers.each { |k, v| req[k] = v }; req.body = body res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |h| h.request(req) } [res.code.to_i, safe_json(res.body)] end def safe_json(s) = (JSON.parse(s) rescue s) def cid = SecureRandom.uuid def t(o = 0) = (Time.now.utc + o).iso8601 # wait for server 20.times { break if (get("/healthz")[0] == 200 rescue false); sleep 0.1 } puts "auth: unsigned request is rejected" code, = post("/api/v1/calls/incoming", { call_id: cid, ts: t }, headers: { "Content-Type" => "application/json" }) eq(code, 401, "401 without bearer/HMAC") puts "happy path over HTTP" c = cid eq(post("/api/v1/calls/incoming", { call_id: c, ts: t(0), number: "601 234 567", presentation: "allowed", dialed_did: "+420800111222", device_id: "sim" })[0], 200, "incoming 200") sc, sb = get("/api/v1/screen-pop?operator=tmobile") eq(sc, 200, "screen-pop 200") truthy(sb["messages"]&.any? { |m| m.dig("person", "name") == "Jan Novák" }, "screen-pop delivered Jan Novák to tmobile operator") eq(post("/api/v1/calls/answered", { call_id: c, ts: t(5) })[0], 200, "answered 200") eq(post("/api/v1/calls/ended", { call_id: c, ts: t(125), duration_s: 120, disconnect_cause: "remote", recording_uuid: "recA" })[0], 200, "ended 200") eq(put_rec("recA", c)[0], 200, "recording upload 200") _, ev = get("/api/v1/events/#{c}") eq(ev["state"], "recording_uploaded", "event reached recording_uploaded") eq(ev["operator"], "tmobile", "operator resolved from DID") # event_view emits :operator; the old ["operator_context"] key was never returned by the API (pre-existing test bug, backend-agnostic) eq(ev["duration_s"], 120, "duration recorded") puts "out-of-order: ended before answered does not regress" c = cid post("/api/v1/calls/incoming", { call_id: c, ts: t(0), number: "+420601234567", presentation: "allowed", dialed_did: "+420800111222" }) post("/api/v1/calls/ended", { call_id: c, ts: t(125), duration_s: 120, disconnect_cause: "remote" }) post("/api/v1/calls/answered", { call_id: c, ts: t(5) }) _, ev = get("/api/v1/events/#{c}") eq(ev["state"], "ended", "late /answered did not regress state") truthy(ev["answered_at"], "answered_at still filled") puts "orphan upload -> quarantine" r = put_rec("orphanRec", "no-such-call") truthy(r[1]["quarantined"], "orphan upload quarantined") puts "missed call + reconcile (server-side clock, so just verify the endpoint path)" c = cid post("/api/v1/calls/incoming", { call_id: c, ts: t(0), number: "+420999888777", presentation: "allowed", dialed_did: "+420800111222" }) eq(post("/api/v1/reconcile", {})[0], 200, "reconcile endpoint 200") puts "device GET channels: signed GET with no Content-Length header returns 200 (regression: read() used to 500 on req.content_length)" eq(signed_get("/api/v1/device/heartbeat?device_id=sim")[0], 200, "device heartbeat 200") gc, gb = signed_get("/api/v1/device/commands?device_id=sim&wait=0") eq(gc, 200, "device commands 200") truthy(gb.is_a?(Hash) && gb.key?("commands"), "device commands returns a commands list") puts "\n#{$pass} passed, #{$fail} failed" exit($fail.zero? ? 0 : 1)