diff --git a/spec/unit/bundle_loader_spec.lua b/spec/unit/bundle_loader_spec.lua index 0a62be7..c0415f3 100644 --- a/spec/unit/bundle_loader_spec.lua +++ b/spec/unit/bundle_loader_spec.lua @@ -609,3 +609,166 @@ runner:then_("^the saas client received a bundle_activated event$", function(ctx end) runner:feature_file_relative("features/bundle_loader.feature") + +describe("bundle_loader targeted direct branch coverage", function() + local bundle_loader + + before_each(function() + mock_ngx.setup_ngx() + bundle_loader = _reload_modules() + end) + + it("returns top-level validation errors for invalid bundle shape", function() + local errors = bundle_loader.validate_bundle(nil) + assert.same({ "bundle must be a table" }, errors) + end) + + it("rejects invalid policy, rule, fallback_limit, and unsupported algorithm", function() + local compiled, err = bundle_loader.load_from_string( + table.concat({ + '{"bundle_version":1,"policies":[', + '{"id":"bad-rule","spec":{"selector":{"pathPrefix":"/a","methods":["GET"]},', + '"rules":[{"limit_keys":["jwt:sub"],"algorithm":"token_bucket",', + '"algorithm_config":{"tokens_per_second":1,"burst":1}}]}},', + '{"id":"bad-fallback","spec":{"selector":{"pathPrefix":"/b","methods":["GET"]},', + '"rules":[{"name":"ok","limit_keys":["jwt:sub"],"algorithm":"token_bucket",', + '"algorithm_config":{"tokens_per_second":1,"burst":1}}],"fallback_limit":"oops"}},', + '{"id":"bad-algo","spec":{"selector":{"pathPrefix":"/c","methods":["GET"]},', + '"rules":[{"name":"weird","limit_keys":["jwt:sub"],"algorithm":"weird",', + '"algorithm_config":{"burst":1}}]}}', + ']}', + }) + ) + assert.is_table(compiled) + assert.is_nil(err) + assert.equals(0, #compiled.policies) + assert.same({ + "policy[1] rule[1]: missing name", + "policy=bad-fallback: fallback_limit must be a table", + "policy=bad-algo rule=weird invalid_algorithm_config: unsupported algorithm", + }, compiled.validation_errors) + end) + + it("queues audit event for malformed signed bundle payload", function() + local events = {} + bundle_loader.init({ + saas_client = { + queue_event = function(event) + events[#events + 1] = event + end, + }, + }) + + local compiled, err = bundle_loader.load_from_string("not-a-signed-bundle", "signing-key") + assert.is_nil(compiled) + assert.equals("signed_bundle_format_error", err) + assert.equals("bundle_rejected", events[1].event_type) + assert.equals("signed_bundle_format_error", events[1].rejection_reason) + end) + + it("returns file and apply guard errors", function() + local compiled, load_err = bundle_loader.load_from_file(nil) + assert.is_nil(compiled) + assert.equals("file_path_required", load_err) + + local applied, apply_err = bundle_loader.apply(nil) + assert.is_nil(applied) + assert.equals("compiled_bundle_required", apply_err) + end) + + it("fails hot reload init when timer registration fails", function() + ngx.timer = { + every = function() + return nil, "boom" + end, + } + + local ok, err = bundle_loader.init_hot_reload(5, "/tmp/nope.json") + assert.is_nil(ok) + assert.equals("hot_reload_init_failed: boom", err) + end) + + it("returns validation errors for top-level bundle edge cases", function() + assert.same({ "bundle_version must be a positive number" }, bundle_loader.validate_bundle({ + bundle_version = 0, + policies = {}, + })) + + assert.same({ "policies must be a table" }, bundle_loader.validate_bundle({ + bundle_version = 1, + policies = false, + })) + end) + + it("returns policy validation errors for invalid selector and mode", function() + local errors = bundle_loader.validate_bundle({ + bundle_version = 1, + policies = { + { id = "bad-selector", spec = {} }, + { id = "bad-mode", spec = { selector = {}, mode = "bogus", rules = {} } }, + }, + }) + assert.same({ + "policy=bad-selector: missing selector", + "policy=bad-mode: invalid mode", + }, errors) + end) + + it("returns policy validation error when policy id is missing", function() + local errors = bundle_loader.validate_bundle({ + bundle_version = 1, + policies = { + { spec = { selector = {}, rules = {} } }, + }, + }) + + assert.same({ + "policy[1]: missing id", + }, errors) + end) + + it("rejects invalid top-level timestamps and malformed policies table", function() + assert.same({ "issued_at_invalid" }, bundle_loader.validate_bundle({ + bundle_version = 1, + policies = {}, + issued_at = "bad", + })) + + assert.same({ "expires_at_invalid" }, bundle_loader.validate_bundle({ + bundle_version = 1, + policies = {}, + expires_at = "bad", + })) + + assert.same({ "policy=bad-rules: rules must be a table" }, bundle_loader.validate_bundle({ + bundle_version = 1, + policies = { + { id = "bad-rules", spec = { selector = {}, rules = false } }, + }, + })) + end) + + it("returns json_string_required for empty payload", function() + local compiled, err = bundle_loader.load_from_string("") + assert.is_nil(compiled) + assert.equals("json_string_required", err) + end) + + it("validates cost_based rules and normalizes selector hosts with ports", function() + local compiled, err = bundle_loader.load_from_string( + table.concat({ + '{"bundle_version":2,"policies":[', + '{"id":"cost-policy","spec":{"selector":{', + '"pathPrefix":"/cost","methods":["POST"],"hosts":["Example.COM:443"]},', + '"rules":[{"name":"cost-rule","limit_keys":["jwt:sub"],', + '"algorithm":"cost_based","algorithm_config":{"budget":100,"period":"1h",', + '"staged_actions":[{"threshold_percent":100,"action":"reject"}]}}]}}', + ']}', + }) + ) + + assert.is_nil(err) + assert.is_table(compiled) + assert.equals("example.com", compiled.policies[1].spec.selector.hosts[1]) + end) +end) diff --git a/spec/unit/circuit_breaker_spec.lua b/spec/unit/circuit_breaker_spec.lua index 707d3d1..ac5b3a4 100644 --- a/spec/unit/circuit_breaker_spec.lua +++ b/spec/unit/circuit_breaker_spec.lua @@ -183,3 +183,23 @@ runner:then_('^current and previous rate keys for limit key "([^"]+)" are cleare end) runner:feature_file_relative("features/circuit_breaker.feature") + +describe("circuit_breaker targeted direct coverage", function() + it("rejects invalid config shapes and fills defaults", function() + local ok, err = circuit_breaker.validate_config(nil) + assert.is_true(ok) + assert.is_nil(err) + + ok, err = circuit_breaker.validate_config({ enabled = "yes" }) + assert.is_nil(ok) + assert.equals("circuit_breaker.enabled must be a boolean", err) + + local config = { enabled = true, spend_rate_threshold_per_minute = 10 } + ok, err = circuit_breaker.validate_config(config) + assert.is_true(ok) + assert.is_nil(err) + assert.equals("reject", config.action) + assert.equals(0, config.auto_reset_after_minutes) + assert.is_false(config.alert) + end) +end) diff --git a/spec/unit/cost_budget_spec.lua b/spec/unit/cost_budget_spec.lua index b19c9bb..d46562e 100644 --- a/spec/unit/cost_budget_spec.lua +++ b/spec/unit/cost_budget_spec.lua @@ -368,3 +368,91 @@ runner:then_("^the period key at now ([%d%.]+) stores usage (%d+)$", function(ct end) runner:feature_file_relative("features/cost_budget.feature") + +describe("cost_budget targeted direct coverage", function() + it("rejects invalid now and budget inputs for compute_period_start", function() + local period_start, err = cost_budget.compute_period_start("minute", nil) + assert.is_nil(period_start) + assert.equals("now must be a number", err) + + period_start, err = cost_budget.compute_period_start("bogus", 10) + assert.is_nil(period_start) + assert.equals("unknown period", err) + end) + + it("fills config defaults and rejects invalid cost configuration", function() + local ok, err = cost_budget.validate_config({ + algorithm = "cost_based", + budget = 100, + period = "1h", + cost_key = "bogus", + }) + assert.is_nil(ok) + assert.equals("cost_key must be fixed, header:, or query:", err) + + local config = { + algorithm = "cost_based", + budget = 100, + period = "1h", + staged_actions = { + { threshold_percent = 100, action = "reject" }, + }, + } + ok, err = cost_budget.validate_config(config) + assert.is_true(ok) + assert.is_nil(err) + assert.equals("fixed", config.cost_key) + assert.equals(1, config.default_cost) + assert.equals(1, config.fixed_cost) + end) + + it("rejects invalid budget, default_cost, and staged_actions entries", function() + local ok, err = cost_budget.validate_config({ + algorithm = "cost_based", + budget = 0, + period = "1h", + staged_actions = { + { threshold_percent = 100, action = "reject" }, + }, + }) + assert.is_nil(ok) + assert.equals("budget must be a positive number", err) + + ok, err = cost_budget.validate_config({ + algorithm = "cost_based", + budget = 10, + period = "1h", + default_cost = 0, + staged_actions = { + { threshold_percent = 100, action = "reject" }, + }, + }) + assert.is_nil(ok) + assert.equals("default_cost must be a positive number", err) + + ok, err = cost_budget.validate_config({ + algorithm = "cost_based", + budget = 10, + period = "1h", + staged_actions = { "bad" }, + }) + assert.is_nil(ok) + assert.equals("staged_action must be a table", err) + end) + + it("falls back to default cost for invalid sources", function() + local cost = cost_budget.resolve_cost({ + _cost_key_kind = "header", + _cost_key_name = "x-cost", + default_cost = 7, + }, { headers = {} }) + assert.equals(7, cost) + + cost = cost_budget.resolve_cost({ + _cost_key_kind = "fixed", + default_cost = 9, + fixed_cost = 0, + }, {}) + assert.equals(9, cost) + end) +end) diff --git a/spec/unit/cost_extractor_spec.lua b/spec/unit/cost_extractor_spec.lua index 9af4826..83d3112 100644 --- a/spec/unit/cost_extractor_spec.lua +++ b/spec/unit/cost_extractor_spec.lua @@ -255,3 +255,43 @@ runner:then_('^reconcile fails with error "([^"]+)"$', function(ctx, expected) end) runner:feature_file_relative("features/cost_extractor.feature") + +describe("cost_extractor targeted direct coverage", function() + it("rejects invalid config bounds and fallback", function() + local ok, err = cost_extractor.validate_config({ max_parseable_body_bytes = 0 }) + assert.is_nil(ok) + assert.equals("max_parseable_body_bytes must be > 0", err) + + ok, err = cost_extractor.validate_config({ fallback = "" }) + assert.is_nil(ok) + assert.equals("fallback must be a non-empty string", err) + end) + + it("returns fallback errors for malformed JSON body", function() + local result, err, meta = cost_extractor.extract_from_response("{bad json", { + fallback = "default_cost", + max_parseable_body_bytes = 1024, + max_parse_time_ms = 100, + }) + assert.is_nil(result) + assert.equals("json_parse_error", err) + assert.is_true(meta.fallback) + end) + + it("rejects invalid stream buffer and parse time limits", function() + local ok, err = cost_extractor.validate_config({ max_stream_buffer_bytes = 0 }) + assert.is_nil(ok) + assert.equals("max_stream_buffer_bytes must be > 0", err) + + ok, err = cost_extractor.validate_config({ max_parse_time_ms = 0 }) + assert.is_nil(ok) + assert.equals("max_parse_time_ms must be > 0", err) + end) + + it("returns config_invalid when config is missing", function() + local result, err, meta = cost_extractor.extract_from_response("{}", nil) + assert.is_nil(result) + assert.equals("config_invalid", err) + assert.is_true(meta.fallback) + end) +end) diff --git a/spec/unit/decision_api_spec.lua b/spec/unit/decision_api_spec.lua index 93a4bb4..135dfe9 100644 --- a/spec/unit/decision_api_spec.lua +++ b/spec/unit/decision_api_spec.lua @@ -894,3 +894,275 @@ runner:then_('^request context ip country is "([^"]+)"$', function(ctx, expected end) runner:feature_file_relative("features/decision_api.feature") + +local function _named_upvalue(fn, target_name) + local index = 1 + while true do + local name, value = debug.getupvalue(fn, index) + if not name then + return nil, nil + end + if name == target_name then + return value, index + end + index = index + 1 + end +end + +local function _set_named_upvalue(fn, target_name, new_value) + local _, index = _named_upvalue(fn, target_name) + assert.is_not_nil(index, "missing upvalue " .. target_name) + debug.setupvalue(fn, index, new_value) +end + +describe("decision_api targeted direct helper coverage", function() + before_each(function() + mock_ngx.setup_package_mock() + mock_ngx.setup_ngx() + ngx.req = { + get_headers = function() return {} end, + get_uri_args = function() return {} end, + read_body = function() end, + get_body_data = function() return nil end, + get_body_file = function() return nil end, + get_method = function() return "POST" end, + } + ngx.var = { + request_method = "GET", + uri = "/v1/chat/completions", + host = "Example.COM:443", + remote_addr = "127.0.0.1", + http_cookie = "", + } + ngx.header = {} + ngx.ctx = {} + ngx.say = function(body) ngx._last_say = body end + ngx.exit = function(code) return code end + end) + + it("covers JWT fallback decoder branches directly", function() + local fallback = _named_upvalue(decision_api.decode_jwt_payload, "_decode_json_fallback") + assert.is_nil(fallback(123)) + assert.same({}, fallback("{}")) + assert.same({ a = 1, b = true, c = false }, fallback('{"a":1,"b":true,"c":false}')) + assert.is_nil(fallback('{"nested":{"x":1}}')) + assert.is_nil(fallback('{"oops":wat}')) + end) + + it("covers request-context helper normalization functions", function() + local normalize_host = _named_upvalue(decision_api.build_request_context, "_normalize_request_host") + local normalize_boolish = _named_upvalue(decision_api.build_request_context, "_normalize_boolish") + local detect_provider = _named_upvalue(decision_api.build_request_context, "_detect_provider") + local safe_var = _named_upvalue(decision_api.build_request_context, "_safe_var") + + assert.is_nil(normalize_host(nil)) + assert.equals("example.com", normalize_host(" Example.COM:443 ")) + assert.equals("https://bad.example", normalize_host("https://bad.example")) + assert.equals("true", normalize_boolish(true)) + assert.equals("false", normalize_boolish("no")) + assert.is_nil(normalize_boolish("maybe")) + assert.equals("anthropic", detect_provider("/proxy/anthropic/v1/messages")) + assert.equals("openai_compatible", detect_provider("/foo/v1/chat/completions")) + + local saved_var = ngx.var + ngx.var = setmetatable({}, { + __index = function() + error("boom") + end, + }) + assert.is_nil(safe_var("http_cookie")) + ngx.var = saved_var + end) + + it("covers reject header preparation defaults", function() + local prepare_reject_headers = _named_upvalue(decision_api.access_handler, "_prepare_reject_headers") + + local headers = prepare_reject_headers({}, { policy_id = "policy-a" }, {}) + assert.equals("1", headers["Retry-After"]) + assert.equals("1", headers["RateLimit-Reset"]) + assert.equals('"policy-a";r=0;t=1', headers["RateLimit"]) + + headers = prepare_reject_headers({}, { + policy_id = "policy-b", + reason = "budget_exceeded", + retry_after = 10, + }, { jwt_claims = { sub = "user-1" }, ip_address = "127.0.0.1", path = "/x" }) + assert.is_true(tonumber(headers["Retry-After"]) <= 10) + end) + + it("covers debug header injection with descriptor details", function() + local inject_debug_headers = _named_upvalue(decision_api.access_handler, "_inject_debug_headers") + local original_enabled = _named_upvalue(inject_debug_headers, "_debug_headers_enabled_for_request") + local original_cookie_valid = _named_upvalue(inject_debug_headers, "_is_debug_cookie_valid") + + _set_named_upvalue(inject_debug_headers, "_debug_headers_enabled_for_request", function() return true end) + _set_named_upvalue(inject_debug_headers, "_is_debug_cookie_valid", function() return true end) + + local headers = inject_debug_headers({}, { + action = "reject", + mode = "shadow", + reason = "rate_limit_exceeded", + policy_id = "policy-1", + rule_name = "rule-1", + latency_us = 123, + matched_policy_count = 2, + debug_descriptors = { + ["jwt:sub"] = "user-1", + ["header:x-e2e-key"] = string.rep("x", 300), + }, + }) + + assert.equals("reject", headers["X-Fairvisor-Decision"]) + assert.equals("shadow", headers["X-Fairvisor-Mode"]) + assert.equals("policy-1", headers["X-Fairvisor-Debug-Policy"]) + assert.equals("rule-1", headers["X-Fairvisor-Debug-Rule"]) + assert.equals("123", headers["X-Fairvisor-Latency-Us"]) + assert.equals("2", headers["X-Fairvisor-Debug-Matched-Policies"]) + assert.is_truthy(headers["X-Fairvisor-Debug-Descriptor-1-Key"]) + assert.is_true(#headers["X-Fairvisor-Debug-Descriptor-1-Value"] <= 256) + + _set_named_upvalue(inject_debug_headers, "_debug_headers_enabled_for_request", original_enabled) + _set_named_upvalue(inject_debug_headers, "_is_debug_cookie_valid", original_cookie_valid) + end) + + it("covers build_request_context body-file and geoip paths", function() + local body_file = "/tmp/decision-api-body.txt" + local file = assert(io.open(body_file, "wb")) + file:write("hello body") + file:close() + + ngx.req = { + get_headers = function() + return { + ["Authorization"] = "Bearer a.eyJzdWIiOiJ1c2VyLTEifQ.c", + ["User-Agent"] = "agent/1.0", + } + end, + get_uri_args = function() return { q = "1" } end, + read_body = function() end, + get_body_data = function() return nil end, + get_body_file = function() return body_file end, + } + ngx.decode_base64 = function() + return '{"sub":"user-1"}' + end + ngx.var.request_method = "POST" + ngx.var.uri = "/anthropic/v1/messages" + ngx.var.host = "Example.COM:443" + + local is_decision_service_mode = _named_upvalue(decision_api.build_request_context, "_is_decision_service_mode") + local config = _named_upvalue(is_decision_service_mode, "_config") + config.mode = "reverse_proxy" + + local deps = _named_upvalue(decision_api.build_request_context, "_deps") + deps.geoip = { + initted = function() return true end, + lookup = function(_, _, kind) + if kind == "country" then + return { country = { iso_code = "DE" } } + end + return { autonomous_system_number = 64512 } + end, + } + + local ctx = decision_api.build_request_context({ descriptor_hints = { needs_user_agent = true } }) + os.remove(body_file) + + assert.equals("example.com", ctx.host) + assert.equals("anthropic", ctx.provider) + assert.equals("DE", ctx.ip_country) + assert.equals(64512, ctx.ip_asn) + assert.equals("hello body", ctx.body) + assert.is_string(ctx.body_hash) + end) + + it("returns 503 when debug cookie cannot be created", function() + local config = _named_upvalue(decision_api.debug_session_handler, "_config") + config.debug_session_secret = "secret" + ngx.req = { + get_method = function() return "POST" end, + get_headers = function() + return { ["X-Fairvisor-Debug-Secret"] = "secret" } + end, + } + ngx.hmac_sha256 = nil + ngx.sha1_bin = nil + + local result = decision_api.debug_session_handler() + assert.equals(503, result) + assert.equals(503, ngx.status) + assert.equals('{"error":"debug_cookie_unavailable"}', ngx._last_say) + end) + + it("covers metric emission error paths", function() + local maybe_emit_metric = _named_upvalue(decision_api.access_handler, "_maybe_emit_metric") + local maybe_emit_remaining = _named_upvalue(decision_api.access_handler, "_maybe_emit_ratelimit_remaining_metric") + local maybe_emit_retry_after = _named_upvalue(decision_api.access_handler, "_maybe_emit_retry_after_metric") + local deps = _named_upvalue(decision_api.access_handler, "_deps") + + deps.health = { + inc = function() + error("metric boom") + end, + set = function() + error("set boom") + end, + } + + maybe_emit_metric({ action = "allow", policy_id = "p1" }) + maybe_emit_remaining({ headers = { ["RateLimit-Remaining"] = "7" }, policy_id = "p1" }, { path = "/x" }) + maybe_emit_retry_after(10) + maybe_emit_remaining({ headers = {} }, {}) + maybe_emit_retry_after(nil) + end) + + it("covers debug cookie parsing and sha1 signing fallback", function() + local debug_enabled = _named_upvalue(decision_api.access_handler, "_debug_headers_enabled_for_request") + local is_cookie_valid = _named_upvalue(debug_enabled, "_is_debug_cookie_valid") + local sign_payload = _named_upvalue(is_cookie_valid, "_sign_debug_payload") + local config = _named_upvalue(sign_payload, "_config") + + config.debug_session_secret = "secret" + ngx.hmac_sha256 = nil + ngx.sha1_bin = function(input) + return string.rep("a", 8) .. input + end + + local signature = sign_payload("9999999999") + ngx.var.http_cookie = "foo=bar; fv_dbg=9999999999." .. signature + assert.is_true(is_cookie_valid()) + + ngx.var.http_cookie = "fv_dbg=bad-token" + assert.is_false(is_cookie_valid()) + + ngx.var.http_cookie = "fv_dbg=1." .. signature + assert.is_false(is_cookie_valid()) + end) + + it("returns init guard errors for missing dependencies", function() + local ok, err = decision_api.init(nil) + assert.is_nil(ok) + assert.equals("deps must be a table", err) + + ok, err = decision_api.init({}) + assert.is_nil(ok) + assert.equals("bundle_loader dependency is required", err) + + ok, err = decision_api.init({ bundle_loader = {} }) + assert.is_nil(ok) + assert.equals("rule_engine dependency is required", err) + end) + + it("returns 503 for missing runtime dependencies in access_handler", function() + local original_deps = _named_upvalue(decision_api.access_handler, "_deps") + original_deps.bundle_loader = {} + original_deps.rule_engine = {} + + local result = decision_api.access_handler() + assert.equals(503, result) + + original_deps.bundle_loader = { get_current = function() return nil end } + result = decision_api.access_handler() + assert.equals(503, result) + end) +end) diff --git a/spec/unit/env_config_spec.lua b/spec/unit/env_config_spec.lua index 4bdb648..eda6938 100644 --- a/spec/unit/env_config_spec.lua +++ b/spec/unit/env_config_spec.lua @@ -143,3 +143,32 @@ describe("env_config cleanup", function() assert.is_function(os.getenv) end) end) + +describe("env_config targeted direct coverage", function() + it("rejects non-table config", function() + local env_config = _reload_module() + local ok, err = env_config.validate(nil) + assert.is_nil(ok) + assert.equals("config must be a table", err) + end) + + it("rejects invalid mode and invalid positive intervals", function() + local env_config = _reload_module() + local ok, err = env_config.validate({ + edge_id = "edge-1", + config_file = "/tmp/policy.json", + mode = "wrapper", + }) + assert.is_nil(ok) + assert.equals("FAIRVISOR_MODE must be decision_service or reverse_proxy", err) + + ok, err = env_config.validate({ + edge_id = "edge-1", + config_file = "/tmp/policy.json", + mode = "decision_service", + config_poll_interval = 0, + }) + assert.is_nil(ok) + assert.equals("FAIRVISOR_CONFIG_POLL_INTERVAL must be a positive number", err) + end) +end) diff --git a/spec/unit/kill_switch_spec.lua b/spec/unit/kill_switch_spec.lua index d4834ad..9cc6581 100644 --- a/spec/unit/kill_switch_spec.lua +++ b/spec/unit/kill_switch_spec.lua @@ -178,3 +178,29 @@ runner:then_("^parsed epoch is nil$", function(ctx) end) runner:feature_file_relative("features/kill_switch.feature") + +describe("kill_switch targeted direct coverage", function() + it("rejects invalid top-level and entry shapes", function() + local ok, err = kill_switch.validate(nil) + assert.is_true(ok) + assert.is_nil(err) + + ok, err = kill_switch.validate({ "bad" }) + assert.is_nil(ok) + assert.equals("kill_switches[1] must be a table", err) + end) + + it("rejects invalid reason type and invalid expires_at format", function() + local ok, err = kill_switch.validate({ + { scope_key = "jwt:sub", scope_value = "u1", reason = 123 }, + }) + assert.is_nil(ok) + assert.equals("kill_switches[1].reason must be a string when set", err) + + ok, err = kill_switch.validate({ + { scope_key = "jwt:sub", scope_value = "u1", expires_at = "bad" }, + }) + assert.is_nil(ok) + assert.equals("kill_switches[1].expires_at must be valid ISO 8601 UTC (YYYY-MM-DDTHH:MM:SSZ)", err) + end) +end) diff --git a/spec/unit/loop_detector_spec.lua b/spec/unit/loop_detector_spec.lua index 142cbb4..5195df8 100644 --- a/spec/unit/loop_detector_spec.lua +++ b/spec/unit/loop_detector_spec.lua @@ -197,3 +197,33 @@ runner:then_('^fingerprint "([^"]+)" stored count is (%d+)$', function(ctx, fing end) runner:feature_file_relative("features/loop_detector.feature") + +describe("loop_detector targeted direct coverage", function() + it("rejects non-table configs and invalid nested loop_detection block", function() + local ok, err = loop_detector.validate_config(nil) + assert.is_true(ok) + assert.is_nil(err) + + ok, err = loop_detector.validate_config({ loop_detection = "bad" }) + assert.is_nil(ok) + assert.equals("loop_detection must be a table", err) + end) + + it("fills defaults and rejects unsupported similarity", function() + local config = { enabled = true, window_seconds = 10, threshold_identical_requests = 3 } + local ok = loop_detector.validate_config(config) + assert.is_true(ok) + assert.equals("reject", config.action) + assert.equals("exact", config.similarity) + + local err + ok, err = loop_detector.validate_config({ + enabled = true, + window_seconds = 10, + threshold_identical_requests = 3, + similarity = "fuzzy", + }) + assert.is_nil(ok) + assert.equals("similarity must be exact", err) + end) +end) diff --git a/spec/unit/saas_client_spec.lua b/spec/unit/saas_client_spec.lua index 3b0fb9a..3f75d35 100644 --- a/spec/unit/saas_client_spec.lua +++ b/spec/unit/saas_client_spec.lua @@ -621,3 +621,116 @@ runner:then_("^queue_event with non%-table argument returns error$", function(_) end) runner:feature_file_relative("features/saas_client.feature") + +local function _reload_saas_client() + package.loaded["fairvisor.saas_client"] = nil + return require("fairvisor.saas_client") +end + +describe("saas_client targeted direct coverage", function() + before_each(function() + mock_ngx.setup_ngx() + end) + + it("validates required config fields", function() + local reloaded = _reload_saas_client() + + local ok, err = reloaded.init(nil, {}) + assert.is_nil(ok) + assert.equals("config must be a table", err) + + ok, err = reloaded.init({ edge_token = "t", saas_url = "https://s" }, {}) + assert.is_nil(ok) + assert.equals("edge_id is required", err) + + ok, err = reloaded.init({ edge_id = "e", saas_url = "https://s" }, {}) + assert.is_nil(ok) + assert.equals("edge_token is required", err) + + ok, err = reloaded.init({ edge_id = "e", edge_token = "t" }, {}) + assert.is_nil(ok) + assert.equals("saas_url is required", err) + end) + + it("validates required deps shape", function() + local reloaded = _reload_saas_client() + local config = { edge_id = "e", edge_token = "t", saas_url = "https://s" } + + local ok, err = reloaded.init(config, nil) + assert.is_nil(ok) + assert.equals("deps must be a table", err) + + ok, err = reloaded.init(config, {}) + assert.is_nil(ok) + assert.equals("deps.bundle_loader is required", err) + + ok, err = reloaded.init(config, { bundle_loader = {} }) + assert.is_nil(ok) + assert.equals("deps.bundle_loader must implement get_current, load_from_string, and apply", err) + + ok, err = reloaded.init(config, { + bundle_loader = { get_current = function() end, load_from_string = function() end, apply = function() end }, + }) + assert.is_nil(ok) + assert.equals("deps.health is required", err) + end) + + it("applies default intervals and buffer sizes on init", function() + local reloaded = _reload_saas_client() + local http = mock_http.new() + http.queue_response("POST", "https://s/api/v1/edge/register", { status = 200 }) + local config = { + edge_id = "e", + edge_token = "t", + saas_url = "https://s", + } + local ok, err = reloaded.init(config, { + bundle_loader = { get_current = function() end, load_from_string = function() end, apply = function() end }, + health = { set = function() end, inc = function() end }, + http_client = http.client, + }) + + assert.is_true(ok) + assert.is_nil(err) + + assert.equals(5, config.heartbeat_interval) + assert.equals(60, config.event_flush_interval) + assert.equals(100, config.max_batch_size) + assert.equals(1000, config.max_buffer_size) + end) + + it("flushes coalesced upstream_error_forwarded events successfully", function() + local reloaded = _reload_saas_client() + local http = mock_http.new() + http.queue_response("POST", "https://s/api/v1/edge/register", { status = 200 }) + http.queue_response("POST", "https://s/api/v1/edge/events", { status = 200 }) + local ok = reloaded.init({ + edge_id = "e", + edge_token = "t", + saas_url = "https://s", + max_batch_size = 10, + max_buffer_size = 10, + }, { + bundle_loader = { get_current = function() end, load_from_string = function() end, apply = function() end }, + health = { set = function() end, inc = function() end }, + http_client = http.client, + }) + + assert.is_true(ok) + reloaded.queue_event({ + event_type = "upstream_error_forwarded", + route = "/v1/chat", + upstream_status = 502, + upstream_host = "backend-a", + }) + reloaded.queue_event({ + event_type = "upstream_error_forwarded", + route = "/v1/chat", + upstream_status = 502, + upstream_host = "backend-a", + }) + + local flushed = reloaded.flush_events() + assert.equals(3, flushed) + end) +end) diff --git a/spec/unit/utils_spec.lua b/spec/unit/utils_spec.lua index 0df52bb..09d5979 100644 --- a/spec/unit/utils_spec.lua +++ b/spec/unit/utils_spec.lua @@ -486,3 +486,100 @@ describe("utils.get_json (chain)", function() assert.is_string(s) end) end) + +local function _reload_utils_with_stubs(stubs) + local saved_loaded = {} + local saved_preload = {} + + for name, loader in pairs(stubs or {}) do + saved_loaded[name] = package.loaded[name] + saved_preload[name] = package.preload[name] + package.loaded[name] = nil + package.preload[name] = loader + end + + local saved_utils = package.loaded["fairvisor.utils"] + package.loaded["fairvisor.utils"] = nil + local reloaded = require("fairvisor.utils") + + package.loaded["fairvisor.utils"] = saved_utils + for name, _ in pairs(stubs or {}) do + package.loaded[name] = saved_loaded[name] + package.preload[name] = saved_preload[name] + end + + return reloaded +end + +describe("utils targeted reload coverage", function() + it("covers constant_time_equals bit path", function() + local reloaded = _reload_utils_with_stubs({ + bit = function() + return { + bxor = function(a, b) return (a == b) and 0 or 1 end, + bor = function(a, b) return ((a ~= 0) or (b ~= 0)) and 1 or 0 end, + } + end, + }) + + assert.is_true(reloaded.constant_time_equals("same", "same")) + assert.is_false(reloaded.constant_time_equals("same", "diff")) + end) + + it("covers cjson.safe JSON chain when available", function() + local reloaded = _reload_utils_with_stubs({ + ["cjson.safe"] = function() + return { + decode = function(input) + if input == '{"a":1}' then + return { a = 1 } + end + return nil + end, + encode = function(tbl) + if tbl and tbl.a == 1 then + return '{"a":1}' + end + return nil + end, + } + end, + }) + + local jl = reloaded.get_json() + local decoded, decode_err = jl.decode('{"a":1}') + local encoded, encode_err = jl.encode({ a = 1 }) + assert.is_nil(decode_err) + assert.equals(1, decoded.a) + assert.is_nil(encode_err) + assert.equals('{"a":1}', encoded) + end) + + it("covers resty.sha256 fallback when ngx.sha256_bin is unavailable", function() + local saved_ngx = _G.ngx + local saved_utils = package.loaded["fairvisor.utils"] + local saved_prel = package.preload["resty.sha256"] + _G.ngx = nil + package.loaded["fairvisor.utils"] = nil + package.preload["resty.sha256"] = function() + return { + new = function() + return { + update = function() end, + final = function() return "digest" end, + } + end, + } + end + local reloaded = require("fairvisor.utils") + + local digest, err = reloaded.sha256("payload") + + package.loaded["fairvisor.utils"] = saved_utils + package.preload["resty.sha256"] = saved_prel + _G.ngx = saved_ngx + + assert.is_nil(err) + assert.equals("digest", digest) + end) +end)