Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions spec/unit/bundle_loader_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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")
71 changes: 71 additions & 0 deletions spec/unit/features/bundle_loader.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
193 changes: 193 additions & 0 deletions spec/unit/streaming_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
]])
Loading
Loading