diff --git a/spec/unit/decision_api_spec.lua b/spec/unit/decision_api_spec.lua index c322134..98ba600 100644 --- a/spec/unit/decision_api_spec.lua +++ b/spec/unit/decision_api_spec.lua @@ -424,6 +424,10 @@ runner:when("^I build request context$", function(ctx) ctx.request_context = decision_api.build_request_context() end) +runner:when("^I build request context with the current bundle$", function(ctx) + ctx.request_context = decision_api.build_request_context(ctx.bundle) +end) + runner:when("^I run the access handler$", function(ctx) ctx.access_result = decision_api.access_handler() end) @@ -675,4 +679,148 @@ runner:then_("^the test cleanup restores globals$", function(ctx) os.getenv = ctx.original_getenv end) +-- ============================================================ +-- Issue #30: targeted coverage additions for decision_api.lua +-- ============================================================ + +runner:given("^ngx say is captured$", function(_) + ngx.say_calls = {} + ngx.say = function(msg) + ngx.say_calls[#ngx.say_calls + 1] = msg + end +end) + +runner:given("^ngx crc32_short is unavailable$", function(_) + ngx.crc32_short = nil +end) + +runner:given("^ngx hmac_sha256 is unavailable for identity hash$", function(_) + ngx.hmac_sha256 = nil +end) + +runner:given("^ngx hmac_sha256 and sha1_bin are unavailable$", function(_) + ngx.hmac_sha256 = nil + ngx.sha1_bin = nil +end) + +runner:given("^the rule engine evaluate is removed$", function(ctx) + ctx.rule_engine.evaluate = nil +end) + +runner:given("^the bundle has descriptor hints with needs_user_agent false$", function(ctx) + ctx.bundle = { + id = "bundle-1", + descriptor_hints = { needs_user_agent = false }, + } +end) + +runner:given("^ngx status is (%d+)$", function(_, status) + ngx.status = tonumber(status) +end) + +runner:given('^upstream address is "([^"]+)"$', function(_, addr) + ngx.var.upstream_addr = addr +end) + +runner:given('^the debug session secret is configured as "([^"]+)"$', function(ctx, secret) + os.getenv = function(key) + if key == "FAIRVISOR_MODE" then return "decision_service" end + return ctx.env_overrides and ctx.env_overrides[key] or nil + end + local ok, err = decision_api.init({ + bundle_loader = ctx.bundle_loader, + rule_engine = ctx.rule_engine, + health = ctx.health, + config = { debug_session_secret = secret }, + }) + assert.is_true(ok) + assert.is_nil(err) + ngx.req.get_method = function() + return ctx.request_method_override or "POST" + end + ngx.say_calls = {} + ngx.say = function(msg) + ngx.say_calls[#ngx.say_calls + 1] = msg + end +end) + +runner:given('^the request method for handler is "([^"]+)"$', function(ctx, method) + ctx.request_method_override = method + ngx.req.get_method = function() + return method + end +end) + +runner:given("^decision api is initialized with a mock saas client$", function(ctx) + ctx.saas_events = {} + ctx.saas_client = { + queue_event = function(event) + ctx.saas_events[#ctx.saas_events + 1] = event + return true + end, + } + os.getenv = function(key) + if key == "FAIRVISOR_MODE" then return "decision_service" end + return ctx.env_overrides and ctx.env_overrides[key] or nil + end + local ok, err = decision_api.init({ + bundle_loader = ctx.bundle_loader, + rule_engine = ctx.rule_engine, + health = ctx.health, + saas_client = ctx.saas_client, + }) + assert.is_true(ok) + assert.is_nil(err) +end) + +runner:when("^I run the debug session handler$", function(ctx) + ctx.debug_session_result = decision_api.debug_session_handler() +end) + +runner:when("^I run the debug logout handler$", function(ctx) + ctx.debug_logout_result = decision_api.debug_logout_handler() +end) + +runner:when("^I run the log handler$", function(_) + decision_api.log_handler() +end) + +runner:then_("^ngx say was called$", function(_) + assert.is_truthy(ngx.say_calls and #ngx.say_calls > 0) +end) + +runner:then_('^response content type is "([^"]+)"$', function(_, ct) + assert.equals(ct, ngx.header["Content-Type"]) +end) + +runner:then_("^the handler exits with status (%d+)$", function(_, status) + assert.equals(tonumber(status), ngx.exit_calls[#ngx.exit_calls]) +end) + +runner:then_('^the saas client received an event of type "([^"]+)"$', function(ctx, event_type) + local found = false + for _, event in ipairs(ctx.saas_events or {}) do + if event.event_type == event_type then + found = true + end + end + assert.is_true(found, "expected saas event type '" .. event_type .. "'") +end) + +runner:then_("^no saas client event was queued$", function(ctx) + assert.equals(0, #(ctx.saas_events or {})) +end) + +runner:then_('^request context provider is "([^"]+)"$', function(ctx, provider) + assert.equals(provider, ctx.request_context.provider) +end) + +runner:then_("^request context provider is nil$", function(ctx) + assert.is_nil(ctx.request_context.provider) +end) + +runner:then_("^request context user agent is nil$", function(ctx) + assert.is_nil(ctx.request_context.user_agent) +end) + runner:feature_file_relative("features/decision_api.feature") diff --git a/spec/unit/features/decision_api.feature b/spec/unit/features/decision_api.feature index 186c2e9..2b122cd 100644 --- a/spec/unit/features/decision_api.feature +++ b/spec/unit/features/decision_api.feature @@ -235,3 +235,248 @@ Feature: Decision API unit behavior Then the access phase proceeds without exiting And shadow mode log entry is emitted And the test cleanup restores globals + + Rule: Jitter hash fallbacks + Scenario: stable jitter uses rolling hash when ngx.crc32_short is unavailable + Given the decision api dependencies are initialized + And ngx crc32_short is unavailable + And the mode is "decision_service" + And the rule engine decision is reject with reason "rate_limit_exceeded" and retry_after 60 + When I run the access handler + Then the request is rejected with status 429 + And the test cleanup restores globals + + Scenario: stable identity hash uses sha1_bin when hmac_sha256 is unavailable + Given the decision api dependencies are initialized + And ngx hmac_sha256 is unavailable for identity hash + And the mode is "decision_service" + And the rule engine decision is reject with reason "rate_limit_exceeded" and retry_after 60 + When I run the access handler + Then the request is rejected with status 429 + And the test cleanup restores globals + + Scenario: stable identity hash uses pure rolling hash when hmac_sha256 and sha1_bin are unavailable + Given the decision api dependencies are initialized + And ngx hmac_sha256 and sha1_bin are unavailable + And the mode is "decision_service" + And the rule engine decision is reject with reason "rate_limit_exceeded" and retry_after 60 + When I run the access handler + Then the request is rejected with status 429 + And the test cleanup restores globals + + Rule: Provider detection + Scenario: provider is anthropic when path contains anthropic segment + Given the decision api dependencies are initialized + And the mode is "decision_service" + And request method is "GET" and path is "/anthropic/v1/messages" + When I build request context + Then request context provider is "anthropic" + And the test cleanup restores globals + + Scenario: provider is openai_compatible for /v1/chat/completions + Given the decision api dependencies are initialized + And the mode is "decision_service" + And request method is "GET" and path is "/v1/chat/completions" + When I build request context + Then request context provider is "openai_compatible" + And the test cleanup restores globals + + Scenario: provider is gemini when path contains gemini segment + Given the decision api dependencies are initialized + And the mode is "decision_service" + And request method is "GET" and path is "/api/gemini/chat" + When I build request context + Then request context provider is "gemini" + And the test cleanup restores globals + + Scenario: provider is nil for unknown path + Given the decision api dependencies are initialized + And the mode is "decision_service" + And request method is "GET" and path is "/api/v1/users" + When I build request context + Then request context provider is nil + And the test cleanup restores globals + + Scenario: provider is mistral when path contains mistral segment + Given the decision api dependencies are initialized + And the mode is "decision_service" + And request method is "GET" and path is "/mistral/chat" + When I build request context + Then request context provider is "mistral" + And the test cleanup restores globals + + Rule: ip tor normalization via normalize_boolish + Scenario: ip tor is true when X-Tor-Exit header is yes + Given the decision api dependencies are initialized + And the mode is "decision_service" + And headers include "X-Tor-Exit" as "yes" + When I build request context + Then request context ip tor is "true" + And the test cleanup restores globals + + Scenario: ip tor is false when X-Tor-Exit header is no + Given the decision api dependencies are initialized + And the mode is "decision_service" + And headers include "X-Tor-Exit" as "no" + When I build request context + Then request context ip tor is "false" + And the test cleanup restores globals + + Scenario: ip tor is true when X-Tor-Exit header is 1 + Given the decision api dependencies are initialized + And the mode is "decision_service" + And headers include "X-Tor-Exit" as "1" + When I build request context + Then request context ip tor is "true" + And the test cleanup restores globals + + Scenario: ip tor is false when X-Tor-Exit header is 0 + Given the decision api dependencies are initialized + And the mode is "decision_service" + And headers include "X-Tor-Exit" as "0" + When I build request context + Then request context ip tor is "false" + And the test cleanup restores globals + + Rule: Bundle descriptor hint suppresses user agent + Scenario: user_agent is nil when bundle descriptor hints needs_user_agent is false + Given the decision api dependencies are initialized + And the mode is "decision_service" + And the bundle has descriptor hints with needs_user_agent false + And headers include "User-Agent" as "TestBot/1.0" + When I build request context with the current bundle + Then request context user agent is nil + And the test cleanup restores globals + + Rule: access_handler 503 paths + Scenario: access_handler returns 503 when rule engine evaluate is missing + Given the decision api dependencies are initialized + And the mode is "decision_service" + And the rule engine evaluate is removed + When I run the access handler + Then the request is rejected with status 503 + And the test cleanup restores globals + + Scenario: access_handler returns 503 when no bundle is loaded + Given the decision api dependencies are initialized + And the mode is "decision_service" + And no bundle is currently loaded + When I run the access handler + Then the request is rejected with status 503 + And the test cleanup restores globals + + Rule: LLM rejection produces JSON error body + Scenario: access_handler produces JSON error body for tpm_exceeded + Given the decision api dependencies are initialized + And the mode is "decision_service" + And ngx say is captured + And the rule engine decision is reject with reason "tpm_exceeded" and retry_after 60 + When I run the access handler + Then the request is rejected with status 429 + And response content type is "application/json" + And ngx say was called + And the test cleanup restores globals + + Scenario: access_handler produces JSON error body for tpd_exceeded + Given the decision api dependencies are initialized + And the mode is "decision_service" + And ngx say is captured + And the rule engine decision is reject with reason "tpd_exceeded" and retry_after 3600 + When I run the access handler + Then the request is rejected with status 429 + And response content type is "application/json" + And ngx say was called + And the test cleanup restores globals + + Rule: Retry-after bucket gt_3600 + Scenario: retry_after 7200 seconds emits gt_3600 bucket metric + Given the decision api dependencies are initialized + And the mode is "decision_service" + And the rule engine decision is reject with reason "rate_limit_exceeded" and retry_after 7200 + When I run the access handler + Then retry after bucket metric is emitted for bucket "gt_3600" + And the test cleanup restores globals + + Rule: log_handler upstream error + Scenario: log_handler queues upstream_error_forwarded event for status 502 + Given the decision api dependencies are initialized + And decision api is initialized with a mock saas client + And ngx status is 502 + And upstream address is "backend:8080" + When I run the log handler + Then the saas client received an event of type "upstream_error_forwarded" + And the test cleanup restores globals + + Scenario: log_handler does not queue event for status 429 + Given the decision api dependencies are initialized + And decision api is initialized with a mock saas client + And ngx status is 429 + When I run the log handler + Then no saas client event was queued + And the test cleanup restores globals + + Scenario: log_handler does not queue event when no saas client + Given the decision api dependencies are initialized + And the mode is "decision_service" + And ngx status is 500 + When I run the log handler + Then the access phase proceeds without exiting + And the test cleanup restores globals + + Rule: debug_session_handler + Scenario: debug_session_handler returns 404 when no secret configured + Given the decision api dependencies are initialized + And the mode is "decision_service" + When I run the debug session handler + Then the handler exits with status 404 + And the test cleanup restores globals + + Scenario: debug_session_handler returns 405 for GET request + Given the decision api dependencies are initialized + And the debug session secret is configured as "my-secret" + And the request method for handler is "GET" + When I run the debug session handler + Then the handler exits with status 405 + And the test cleanup restores globals + + Scenario: debug_session_handler returns 403 for wrong secret + Given the decision api dependencies are initialized + And the debug session secret is configured as "my-secret" + And the request method for handler is "POST" + And headers include "X-Fairvisor-Debug-Secret" as "wrong-secret" + When I run the debug session handler + Then the handler exits with status 403 + And the test cleanup restores globals + + Scenario: debug_session_handler returns 204 for valid secret + Given the decision api dependencies are initialized + And the debug session secret is configured as "my-secret" + And the request method for handler is "POST" + And headers include "X-Fairvisor-Debug-Secret" as "my-secret" + When I run the debug session handler + Then the handler exits with status 204 + And the test cleanup restores globals + + Rule: debug_logout_handler + Scenario: debug_logout_handler returns 404 when no secret configured + Given the decision api dependencies are initialized + And the mode is "decision_service" + When I run the debug logout handler + Then the handler exits with status 404 + And the test cleanup restores globals + + Scenario: debug_logout_handler returns 405 for GET request + Given the decision api dependencies are initialized + And the debug session secret is configured as "my-secret" + And the request method for handler is "GET" + When I run the debug logout handler + Then the handler exits with status 405 + And the test cleanup restores globals + + Scenario: debug_logout_handler returns 204 and clears cookie on POST + Given the decision api dependencies are initialized + And the debug session secret is configured as "my-secret" + And the request method for handler is "POST" + When I run the debug logout handler + Then the handler exits with status 204 + And the test cleanup restores globals diff --git a/spec/unit/features/saas_client.feature b/spec/unit/features/saas_client.feature index 3538df1..f08efce 100644 --- a/spec/unit/features/saas_client.feature +++ b/spec/unit/features/saas_client.feature @@ -131,3 +131,117 @@ Feature: SaaS protocol client unit behavior When the heartbeat timer callback runs And the event flush timer callback runs Then the events payload flags clock skew + + Rule: Uninitialized client guards + Scenario: flush_events returns 0 when client is not initialized + Given the nginx mock environment with timer capture is reset + And a default SaaS client config + And default bundle_loader and health dependencies + When I call flush_events on a fresh client + Then flush events returns 0 + + Scenario: pull_config returns error when client is not initialized + Given the nginx mock environment with timer capture is reset + And a default SaaS client config + And default bundle_loader and health dependencies + When I call pull_config on a fresh client + Then pull_config returns not initialized error + + Rule: Auth token security + Scenario: edge token with newline character uses empty bearer in requests + Given the nginx mock environment with timer capture is reset + And a default SaaS client config with token containing newline + And default bundle_loader and health dependencies + And registration succeeds + When the client is initialized + Then initialization succeeds + And the register endpoint used empty bearer auth + + Rule: Event coalescing + Scenario: two coalesceable events with same signature produce one buffered then one summary on flush + Given the nginx mock environment with timer capture is reset + And a default SaaS client config + And default bundle_loader and health dependencies + And registration succeeds + And events endpoint accepts one batch + And the client is initialized + When I queue a coalesceable event with route "/api/v1/test" + And I queue the same coalesceable event again + And I force flush events + Then the flushed batch includes the original and coalesced summary event + + Rule: Subject ID hashing + Scenario: queue_event with subject_id hashes the ID and removes raw field + Given the nginx mock environment with timer capture is reset + And a default SaaS client config + And default bundle_loader and health dependencies + And registration succeeds + And events endpoint accepts one batch + And the client is initialized + When I queue an event with subject_id "user-secret-123" + And I force flush events + Then the flushed event has subject_id_hash and no raw subject_id + + Rule: Non-retriable event flush status + Scenario: non-retriable status 401 on event flush counts as error + Given the nginx mock environment with timer capture is reset + And a default SaaS client config + And default bundle_loader and health dependencies + And registration succeeds + And events endpoint fails with status 401 + And the client is initialized + And I queue events with ids: 1 + When I force flush events + Then events_sent_total has one error increment + + Rule: Bundle load rejection ack + Scenario: config pull with bundle load rejection acks as rejected + Given the nginx mock environment with timer capture is reset + And a default SaaS client config + And default bundle_loader and health dependencies + And registration succeeds + And config poll interval is 999999 seconds + And heartbeat succeeds with config update available + And config pull returns 200 with rejecting bundle + And the ack endpoint accepts the rejection + And the client is initialized + When the heartbeat timer callback runs + Then the bundle is acked as rejected + + Rule: Half-open circuit failure reopens circuit + Scenario: failure during half_open state transitions circuit back to disconnected + Given the nginx mock environment with timer capture is reset + And a default SaaS client config + And default bundle_loader and health dependencies + And registration succeeds + And config poll interval is 999999 seconds + And heartbeat returns retriable failure 5 times + And heartbeat returns retriable failure 1 times + And the client is initialized + When the heartbeat timer callback runs 5 times + Then the circuit state becomes disconnected + Given time advances by 30 seconds + When the heartbeat timer callback runs + Then the circuit state becomes disconnected + + Rule: Heartbeat string body extract_payload + Scenario: heartbeat response with JSON string body is correctly parsed + Given the nginx mock environment with timer capture is reset + And a default SaaS client config + And default bundle_loader and health dependencies + And registration succeeds + And config poll interval is 999999 seconds + And heartbeat succeeds with JSON string body + And the client is initialized + When the heartbeat timer callback runs + Then the circuit state becomes connected + + Rule: Timer premature callback + Scenario: heartbeat timer with premature true returns without making HTTP request + Given the nginx mock environment with timer capture is reset + And a default SaaS client config + And default bundle_loader and health dependencies + And registration succeeds + And the client is initialized + When the heartbeat timer callback runs with premature true + Then no heartbeat request was made diff --git a/spec/unit/saas_client_spec.lua b/spec/unit/saas_client_spec.lua index 10e5826..27e6848 100644 --- a/spec/unit/saas_client_spec.lua +++ b/spec/unit/saas_client_spec.lua @@ -402,4 +402,149 @@ runner:then_("^backoff suppresses immediate retry and allows retry after (.+) se assert.equals(2, heartbeat_calls_after_wait) end) +-- ============================================================ +-- Issue #30: targeted coverage additions for saas_client.lua +-- ============================================================ + +runner:given("^a default SaaS client config with token containing newline$", function(ctx) + ctx.config = { + edge_id = "edge-test-1", + edge_token = "token\ninjected", + saas_url = "https://saas.example", + heartbeat_interval = 5, + event_flush_interval = 60, + config_poll_interval = 30, + max_batch_size = 2, + max_buffer_size = 3, + } +end) + +runner:given("^config pull returns 200 with rejecting bundle$", function(ctx) + ctx.http.queue_response("GET", ctx.config.saas_url .. "/api/v1/edge/config", { + status = 200, + body = { reject = true, version = "v-reject" }, + }) +end) + +runner:given("^the ack endpoint accepts the rejection$", function(ctx) + ctx.http.queue_response("POST", ctx.config.saas_url .. "/api/v1/edge/config/ack", { status = 200 }) +end) + +runner:given("^heartbeat succeeds with JSON string body$", function(ctx) + ctx.http.queue_response("POST", ctx.config.saas_url .. "/api/v1/edge/heartbeat", { + status = 200, + body = '{"config_update_available":false,"server_time":' .. tostring(ctx.time.now()) .. '}', + }) +end) + +runner:when("^I call flush_events on a fresh client$", function(ctx) + local saved = package.loaded["fairvisor.saas_client"] + package.loaded["fairvisor.saas_client"] = nil + local fresh = require("fairvisor.saas_client") + ctx.flush_result = fresh.flush_events() + package.loaded["fairvisor.saas_client"] = saved +end) + +runner:when("^I call pull_config on a fresh client$", function(ctx) + local saved = package.loaded["fairvisor.saas_client"] + package.loaded["fairvisor.saas_client"] = nil + local fresh = require("fairvisor.saas_client") + ctx.pull_ok, ctx.pull_err = fresh.pull_config() + package.loaded["fairvisor.saas_client"] = saved +end) + +runner:when('^I queue a coalesceable event with route "([^"]+)"$', function(_, route) + saas_client.queue_event({ + event_type = "request_throttled", + route = route, + reason_code = "rate_limit", + status_code = 429, + }) +end) + +runner:when("^I queue the same coalesceable event again$", function(_) + saas_client.queue_event({ + event_type = "request_throttled", + route = "/api/v1/test", + reason_code = "rate_limit", + status_code = 429, + }) +end) + +runner:when('^I queue an event with subject_id "([^"]+)"$', function(_, subject_id) + saas_client.queue_event({ + event_type = "request_rejected", + route = "/api/test", + subject_id = subject_id, + }) +end) + +runner:when("^the heartbeat timer callback runs with premature true$", function(ctx) + ctx.timers[1].fn(true) +end) + +runner:then_("^flush events returns 0$", function(ctx) + assert.equals(0, ctx.flush_result) +end) + +runner:then_("^pull_config returns not initialized error$", function(ctx) + assert.is_nil(ctx.pull_ok) + assert.is_truthy(ctx.pull_err) + assert.is_truthy(ctx.pull_err:find("not initialized")) +end) + +runner:then_("^the register endpoint used empty bearer auth$", function(ctx) + for _, request in ipairs(ctx.http.requests) do + if request.method == "POST" and request.url == ctx.config.saas_url .. "/api/v1/edge/register" then + assert.equals("Bearer ", request.headers.Authorization) + return + end + end + assert.is_true(false, "register request not found") +end) + +runner:then_("^the flushed batch includes the original and coalesced summary event$", function(ctx) + assert.equals(2, ctx.flushed) +end) + +runner:then_("^the flushed event has subject_id_hash and no raw subject_id$", function(ctx) + for _, request in ipairs(ctx.http.requests) do + if request.url == ctx.config.saas_url .. "/api/v1/edge/events" then + local events = request.body and request.body.events or {} + -- Find the request_rejected event (init also queues an edge_started event) + local target_ev = nil + for _, ev in ipairs(events) do + if ev.event_type == "request_rejected" then + target_ev = ev + break + end + end + assert.is_not_nil(target_ev, "expected request_rejected event in batch") + assert.is_not_nil(target_ev.subject_id_hash, "subject_id_hash should be set") + assert.is_nil(target_ev.subject_id, "raw subject_id should be removed") + return + end + end + assert.is_true(false, "no events POST request found") +end) + +runner:then_("^the bundle is acked as rejected$", function(ctx) + for _, request in ipairs(ctx.http.requests) do + if request.method == "POST" and request.url == ctx.config.saas_url .. "/api/v1/edge/config/ack" then + assert.equals("rejected", request.body.status) + return + end + end + assert.is_true(false, "config/ack POST request not found") +end) + +runner:then_("^no heartbeat request was made$", function(ctx) + for _, request in ipairs(ctx.http.requests) do + if request.url == ctx.config.saas_url .. "/api/v1/edge/heartbeat" then + assert.is_true(false, "unexpected heartbeat request was made") + end + end + assert.is_true(true) +end) + runner:feature_file_relative("features/saas_client.feature")