diff --git a/spec/unit/bundle_loader_spec.lua b/spec/unit/bundle_loader_spec.lua index 50ef123..0a62be7 100644 --- a/spec/unit/bundle_loader_spec.lua +++ b/spec/unit/bundle_loader_spec.lua @@ -518,4 +518,94 @@ runner:given("^circuit breaker state for \"([^\"]+)\" is open in shared dict$", ctx.env.dict:set(state_key, "open:12345") end) +-- ============================================================ +-- Issue #25: targeted coverage additions for bundle_loader.lua +-- ============================================================ + +runner:given("^a bundle with a ua descriptor limit key$", function(ctx) + ctx.bundle = mock_bundle.new_bundle({ + bundle_version = 42, + policies = { + { + id = "policy-ua-test", + spec = { + selector = { pathPrefix = "/v1/", methods = { "GET" } }, + mode = "enforce", + rules = { + { name = "ua-rate", limit_keys = { "ua:bot" }, algorithm = "token_bucket", + algorithm_config = { tokens_per_second = 10, burst = 20 } }, + }, + }, + }, + }, + }) + ctx.payload = mock_bundle.encode(ctx.bundle) +end) + +runner:given("^a valid sha1%-signed bundle payload$", function(ctx) + ctx.bundle = mock_bundle.new_bundle({ bundle_version = 42 }) + local payload = mock_bundle.encode(ctx.bundle) + local raw_sig = _G.ngx.hmac_sha1(ctx.signing_key, payload) + ctx.signed_payload = _G.ngx.encode_base64(raw_sig) .. "\n" .. payload +end) + +runner:given("^ngx hmac_sha256 is unavailable$", function(_ctx) + _G.ngx.hmac_sha256 = nil +end) + +runner:given("^ngx hmac_sha256 and hmac_sha1 are unavailable$", function(_ctx) + _G.ngx.hmac_sha256 = nil + _G.ngx.hmac_sha1 = nil +end) + +runner:given("^ngx sha1_bin is unavailable$", function(_ctx) + _G.ngx.sha1_bin = nil +end) + +runner:given("^ngx sha1_bin and md5 are unavailable$", function(_ctx) + _G.ngx.sha1_bin = nil + _G.ngx.md5 = nil +end) + +runner:given("^ngx timer is unavailable$", function(_ctx) + _G.ngx.timer = nil +end) + +runner:given("^a nonexistent file path is set$", function(ctx) + ctx.file_path = "/tmp/__no_such_bundle_file_xyz_issue25__" +end) + +runner:given("^the loader is initialized with a mock saas client$", function(ctx) + ctx.saas_events = {} + ctx.loader.init({ + dict = ctx.env.dict, + saas_client = { + queue_event = function(event) + ctx.saas_events[#ctx.saas_events + 1] = event + end, + }, + }) +end) + +runner:then_("^the compiled bundle descriptor hints needs_user_agent is true$", function(ctx) + assert.is_table(ctx.compiled.descriptor_hints) + assert.is_true(ctx.compiled.descriptor_hints.needs_user_agent) +end) + +runner:then_("^hot reload initialization fails with \"([^\"]+)\"$", function(ctx, expected_err) + assert.is_nil(ctx.hot_reload_ok) + assert.equals(expected_err, ctx.hot_reload_err) +end) + +runner:then_("^the saas client received a bundle_activated event$", function(ctx) + local found = false + for _, event in ipairs(ctx.saas_events or {}) do + if event.event_type == "bundle_activated" then + found = true + break + end + end + assert.is_true(found, "expected bundle_activated event in saas_events") +end) + runner:feature_file_relative("features/bundle_loader.feature") diff --git a/spec/unit/features/bundle_loader.feature b/spec/unit/features/bundle_loader.feature index 223ceca..9546722 100644 --- a/spec/unit/features/bundle_loader.feature +++ b/spec/unit/features/bundle_loader.feature @@ -236,3 +236,74 @@ Feature: Policy bundle loader And current version is nil When I load the unsigned bundle Then the load fails with error "reset_circuit_breakers[1] must be a non-empty string" + + Rule: Descriptor hints – ua key detection + Scenario: bundle with ua:bot limit key sets needs_user_agent hint + Given the bundle loader environment is reset + And a bundle with a ua descriptor limit key + And current version is nil + When I load the unsigned bundle + Then the load succeeds + And the compiled bundle descriptor hints needs_user_agent is true + + Rule: HMAC fallback paths + Scenario: sha1 fallback HMAC is used when hmac_sha256 unavailable + Given the bundle loader environment is reset + And a valid sha1-signed bundle payload + And current version is nil + And ngx hmac_sha256 is unavailable + When I load the signed bundle + Then the load succeeds + And the compiled bundle has version 42 and non-nil hash + + Scenario: load fails with hmac_sha256_unavailable when no HMAC available + Given the bundle loader environment is reset + And a valid signed bundle payload + And current version is nil + And ngx hmac_sha256 and hmac_sha1 are unavailable + When I load the signed bundle + Then the load fails with error "hmac_sha256_unavailable" + + Rule: Compute hash fallbacks + Scenario: bundle loads successfully using md5 fallback hash + Given the bundle loader environment is reset + And ngx sha1_bin is unavailable + And a valid unsigned bundle payload + And current version is nil + When I load the unsigned bundle + Then the load succeeds + And the compiled bundle has version 42 and non-nil hash + + Scenario: bundle loads successfully using pure length-prefix fallback hash + Given the bundle loader environment is reset + And ngx sha1_bin and md5 are unavailable + And a valid unsigned bundle payload + And current version is nil + When I load the unsigned bundle + Then the load succeeds + And the compiled bundle has version 42 and non-nil hash + + Rule: Init hot reload without ngx.timer + Scenario: init_hot_reload returns error when ngx.timer is unavailable + Given the bundle loader environment is reset + And ngx timer is unavailable + When I initialize hot reload every 5 seconds + Then hot reload initialization fails with "ngx_timer_unavailable" + + Rule: File loading edge cases + Scenario: load_from_file returns file_not_found for nonexistent path + Given the bundle loader environment is reset + And a nonexistent file path is set + And current version is nil + When I load from file + Then the load fails with error "file_not_found" + + Rule: Saas client audit event on apply + Scenario: bundle activation queues a bundle_activated event to saas client + Given the bundle loader environment is reset + And the loader is initialized with a mock saas client + And a valid unsigned bundle payload + And current version is nil + When I load the unsigned bundle + And I apply the compiled bundle + Then the saas client received a bundle_activated event diff --git a/spec/unit/streaming_spec.lua b/spec/unit/streaming_spec.lua index 3361300..faf791d 100644 --- a/spec/unit/streaming_spec.lua +++ b/spec/unit/streaming_spec.lua @@ -345,6 +345,135 @@ runner:then_("^reconcile is not called$", function(_) assert.equals(0, #reconcile_calls) end) +-- Issue #25: additional step definitions for coverage gaps +runner:given("^a streaming context with include_partial_usage disabled$", function(ctx) + ctx.config = { + max_completion_tokens = 50, + streaming = { + enabled = true, + enforce_mid_stream = true, + buffer_tokens = 50, + on_limit_exceeded = "graceful_close", + include_partial_usage = false, + }, + } + ctx.request_context = { + body = '{"stream":true}', + headers = { Accept = "text/event-stream" }, + } + ctx.reservation = { + key = "tenant-no-usage", + estimated_total = 5000, + prompt_tokens = 10, + is_shadow = false, + } + ctx.stream_ctx = streaming.init_stream(ctx.config, ctx.request_context, ctx.reservation) +end) + +runner:given("^a streaming context with leftover buffer data$", function(ctx) + ctx.config = { + max_completion_tokens = 5000, + streaming = { + enabled = true, + enforce_mid_stream = true, + buffer_tokens = 100, + on_limit_exceeded = "graceful_close", + include_partial_usage = true, + }, + } + ctx.request_context = { body = '{"stream":true}', headers = {} } + ctx.reservation = { + key = "tenant-buf", + estimated_total = 5000, + prompt_tokens = 0, + is_shadow = false, + } + ctx.stream_ctx = streaming.init_stream(ctx.config, ctx.request_context, ctx.reservation) +end) + +runner:given("^a streaming context is truncated$", function(ctx) + ctx.stream_ctx.truncated = true +end) + +runner:when("^I run body_filter with a non%-eof chunk on the truncated stream$", function(ctx) + ctx.output = streaming.body_filter("some data", false) +end) + +runner:when("^I run body_filter with eof on the truncated stream$", function(ctx) + ctx.output = streaming.body_filter("", true) +end) + +runner:when("^I send an empty data%-only SSE event$", function(ctx) + ctx.output = streaming.body_filter("data:\n\n", false) +end) + +runner:when("^I run body_filter with partial SSE then eof$", function(ctx) + -- Send a partial event (no double-newline terminator) then close with eof + ctx.output = streaming.body_filter("data: incomplete", false) + ctx.output2 = streaming.body_filter("", true) +end) + +runner:then_("^the truncated stream returns empty string$", function(ctx) + assert.equals("", ctx.output) +end) + +runner:then_("^the empty data event passes through with zero tokens$", function(ctx) + assert.equals(0, ctx.stream_ctx.tokens_used) + assert.is_string(ctx.output) +end) + +runner:then_("^the partial buffer is flushed on eof$", function(ctx) + -- The unfinished SSE fragment should be in output2 (flushed at eof) + assert.is_truthy(string.find(ctx.output2 or "", "incomplete", 1, true) + or string.find((ctx.output or "") .. (ctx.output2 or ""), "incomplete", 1, true)) +end) + +runner:then_("^the termination event has no usage field$", function(ctx) + assert.is_nil(string.find(ctx.output, "usage", 1, true)) +end) + +runner:given("^streaming config with non%-boolean enabled$", function(ctx) + ctx.validation_config = { streaming = { enabled = "yes" } } +end) + +runner:given("^streaming config with non%-boolean enforce_mid_stream$", function(ctx) + ctx.validation_config = { streaming = { enforce_mid_stream = 1 } } +end) + +runner:given("^streaming config with non%-boolean include_partial_usage$", function(ctx) + ctx.validation_config = { streaming = { include_partial_usage = "true" } } +end) + +runner:given("^streaming config with invalid on_limit_exceeded$", function(ctx) + ctx.validation_config = { streaming = { on_limit_exceeded = "drop" } } +end) + +runner:then_("^streaming config validation fails with enabled error$", function(ctx) + assert.is_nil(ctx.ok) + assert.matches("enabled must be a boolean", ctx.err) +end) + +runner:then_("^streaming config validation fails with enforce_mid_stream error$", function(ctx) + assert.is_nil(ctx.ok) + assert.matches("enforce_mid_stream must be a boolean", ctx.err) +end) + +runner:then_("^streaming config validation fails with include_partial_usage error$", function(ctx) + assert.is_nil(ctx.ok) + assert.matches("include_partial_usage must be a boolean", ctx.err) +end) + +runner:then_("^streaming config validation fails with on_limit_exceeded error$", function(ctx) + assert.is_nil(ctx.ok) + assert.matches("on_limit_exceeded must be graceful_close or error_chunk", ctx.err) +end) + +runner:then_("^is_streaming returns false for nil input$", function(ctx) + assert.is_false(streaming.is_streaming(nil)) + assert.is_false(streaming.is_streaming(42)) + assert.is_false(streaming.is_streaming("string")) +end) + runner:feature([[ Feature: SSE streaming enforcement module behavior Rule: Streaming detection and config validation @@ -466,4 +595,68 @@ Feature: SSE streaming enforcement module behavior When I run body_filter on a non-streaming request chunk Then non-streaming chunks pass through unchanged And reconcile is not called + + Rule: validate_config type error branches (issue #25) + Scenario: validate_config rejects non-boolean enabled + Given the nginx mock environment is reset + And streaming config with non-boolean enabled + When I validate streaming config + Then streaming config validation fails with enabled error + + Scenario: validate_config rejects non-boolean enforce_mid_stream + Given the nginx mock environment is reset + And streaming config with non-boolean enforce_mid_stream + When I validate streaming config + Then streaming config validation fails with enforce_mid_stream error + + Scenario: validate_config rejects non-boolean include_partial_usage + Given the nginx mock environment is reset + And streaming config with non-boolean include_partial_usage + When I validate streaming config + Then streaming config validation fails with include_partial_usage error + + Scenario: validate_config rejects invalid on_limit_exceeded value + Given the nginx mock environment is reset + And streaming config with invalid on_limit_exceeded + When I validate streaming config + Then streaming config validation fails with on_limit_exceeded error + + Rule: is_streaming edge cases (issue #25) + Scenario: is_streaming returns false for non-table input + Given the nginx mock environment is reset + Then is_streaming returns false for nil input + + Rule: body_filter truncated and buffer edge cases (issue #25) + Scenario: truncated stream swallows non-eof chunks + Given the nginx mock environment is reset + And a streaming context with max_completion_tokens 100 and buffer_tokens 100 + And a streaming context is truncated + When I run body_filter with a non-eof chunk on the truncated stream + Then the truncated stream returns empty string + And reconcile is not called + + Scenario: truncated stream triggers reconcile on eof + Given the nginx mock environment is reset + And a streaming context with max_completion_tokens 100 and buffer_tokens 100 + And a streaming context is truncated + When I run body_filter with eof on the truncated stream + Then reconcile is called with actual total tokens 80 + + Scenario: empty data: event passes through with zero token contribution + Given the nginx mock environment is reset + And a streaming context with max_completion_tokens 200 and buffer_tokens 100 + When I send an empty data-only SSE event + Then the empty data event passes through with zero tokens + + Scenario: partial SSE fragment is flushed on eof + Given the nginx mock environment is reset + And a streaming context with leftover buffer data + When I run body_filter with partial SSE then eof + Then the partial buffer is flushed on eof + + Scenario: termination event omits usage when include_partial_usage is false + Given the nginx mock environment is reset + And a streaming context with include_partial_usage disabled + When I run body_filter with two 60 token delta events in one chunk + Then the termination event has no usage field ]]) diff --git a/spec/unit/utils_spec.lua b/spec/unit/utils_spec.lua index ca971a4..0df52bb 100644 --- a/spec/unit/utils_spec.lua +++ b/spec/unit/utils_spec.lua @@ -252,3 +252,237 @@ describe("utils.fairvisor_json (inlined JSON codec)", function() end) end) end) + +-- ============================================================ +-- Issue #25: targeted coverage additions for utils.lua +-- ============================================================ + +describe("utils.now", function() + it("returns 0 when ngx is not available", function() + local saved = _G.ngx + _G.ngx = nil + local v = utils.now() + _G.ngx = saved + assert.equals(0, v) + end) + + it("returns ngx.now() value when ngx is available", function() + local saved = _G.ngx + _G.ngx = { now = function() return 12345.6 end } + local v = utils.now() + _G.ngx = saved + assert.equals(12345.6, v) + end) +end) + +describe("utils.safe_require", function() + it("returns nil for a module that does not exist", function() + assert.is_nil(utils.safe_require("__no_such_module_xyz__")) + end) + + it("returns the module for an existing module", function() + local m = utils.safe_require("fairvisor.utils") + assert.is_not_nil(m) + assert.is_function(m.now) + end) +end) + +describe("utils.to_hex", function() + it("converts binary bytes to hex string", function() + assert.equals("00ffab", utils.to_hex("\0\255\171")) + end) + + it("returns empty string for empty input", function() + assert.equals("", utils.to_hex("")) + end) + + it("returns nil for non-string input", function() + assert.is_nil(utils.to_hex(nil)) + assert.is_nil(utils.to_hex(42)) + end) +end) + +describe("utils.constant_time_equals", function() + it("returns false when first argument is not a string", function() + assert.is_false(utils.constant_time_equals(nil, "x")) + assert.is_false(utils.constant_time_equals(1, "x")) + end) + + it("returns false when second argument is not a string", function() + assert.is_false(utils.constant_time_equals("x", nil)) + end) + + it("returns false for different strings of same length", function() + assert.is_false(utils.constant_time_equals("abc", "xyz")) + end) + + it("returns false for different-length strings", function() + assert.is_false(utils.constant_time_equals("ab", "abc")) + end) + + it("returns true for identical strings", function() + assert.is_true(utils.constant_time_equals("secret", "secret")) + end) + + it("returns true for empty strings", function() + assert.is_true(utils.constant_time_equals("", "")) + end) +end) + +describe("utils.encode_base64 without ngx", function() + it("returns nil when ngx.encode_base64 is not available", function() + local saved = _G.ngx + _G.ngx = nil + local result = utils.encode_base64("hello") + _G.ngx = saved + assert.is_nil(result) + end) +end) + +describe("utils.decode_base64 fallback (no ngx)", function() + it("decodes a standard base64 string", function() + local saved = _G.ngx + _G.ngx = nil + local result = utils.decode_base64("aGVsbG8=") + _G.ngx = saved + assert.equals("hello", result) + end) + + it("decodes base64 without padding", function() + local saved = _G.ngx + _G.ngx = nil + local result = utils.decode_base64("YWJj") + _G.ngx = saved + assert.equals("abc", result) + end) + + it("returns nil for a string containing an invalid character", function() + local saved = _G.ngx + _G.ngx = nil + local result = utils.decode_base64("not!valid!!") + _G.ngx = saved + assert.is_nil(result) + end) +end) + +describe("utils.base64url_decode", function() + it("returns nil for non-string input", function() + assert.is_nil(utils.base64url_decode(nil)) + assert.is_nil(utils.base64url_decode(42)) + end) + + it("returns nil for empty string", function() + assert.is_nil(utils.base64url_decode("")) + end) + + it("returns nil when remainder after padding is 1 (invalid)", function() + local saved = _G.ngx + _G.ngx = nil + local result = utils.base64url_decode("a") + _G.ngx = saved + assert.is_nil(result) + end) + + it("decodes a valid base64url string (no padding)", function() + local saved = _G.ngx + _G.ngx = nil + local result = utils.base64url_decode("aGVsbG8") + _G.ngx = saved + assert.equals("hello", result) + end) +end) + +describe("utils.sha256", function() + it("returns nil for nil input (guard branch)", function() + local result = utils.sha256(nil) + assert.is_nil(result) + end) + + it("returns a value or graceful error without crashing", function() + local result, err = utils.sha256("test-input") + assert.is_true(result ~= nil or err ~= nil) + end) +end) + +describe("utils.fairvisor_json additional branches", function() + it("encode returns error for object with non-integer-sequence key", function() + local s, err = json.encode({ [1.5] = "v" }) + assert.is_nil(s) + assert.is_string(err) + end) + + it("encode returns nil and error for value of unsupported type (function)", function() + local s, err = json.encode({ fn = function() end }) + assert.is_nil(s) + assert.is_string(err) + end) + + it("decode returns error for invalid number '-'", function() + local v, err = json.decode("-") + assert.is_nil(v) + assert.is_string(err) + end) + + it("decode returns error for unterminated array", function() + local v, err = json.decode("[1,2") + assert.is_nil(v) + assert.is_string(err) + end) + + it("decode returns error for unterminated object", function() + local v, err = json.decode('{"a":1') + assert.is_nil(v) + assert.is_string(err) + end) + + it("decode handles escape sequences b, f, r and /", function() + local v, err = json.decode('{"b":"\\b","f":"\\f","r":"\\r","sl":"\\/"}') + assert.is_nil(err) + assert.equals("\b", v.b) + assert.equals("\f", v.f) + assert.equals("\r", v.r) + assert.equals("/", v.sl) + end) + + it("decode returns error for expected ',' or ']' missing in array", function() + local v, err = json.decode("[1 2]") + assert.is_nil(v) + assert.is_string(err) + end) + + it("decode returns error for expected ',' or '}' missing in object", function() + local v, err = json.decode('{"a":1 "b":2}') + assert.is_nil(v) + assert.is_string(err) + end) +end) + +describe("utils.get_json (chain)", function() + it("returns a table with decode and encode functions", function() + local jl = utils.get_json() + assert.is_table(jl) + assert.is_function(jl.decode) + assert.is_function(jl.encode) + end) + + it("decode handles valid JSON", function() + local jl = utils.get_json() + local v, err = jl.decode('{"x":1}') + assert.is_nil(err) + assert.equals(1, v.x) + end) + + it("decode returns error for non-string input", function() + local jl = utils.get_json() + local v, err = jl.decode(123) + assert.is_nil(v) + assert.is_string(err) + end) + + it("encode handles a table", function() + local jl = utils.get_json() + local s, err = jl.encode({ k = "v" }) + assert.is_nil(err) + assert.is_string(s) + end) +end)