Skip to content

Commit 50389f7

Browse files
test: add integration tests for multi-backend configs, agent events, and health gates
Three new integration test suites proving the control plane works end-to-end: 1. Multi-backend config (3 tests): distinct KDL configs (WAF vs auth) are correctly scoped per project, bundles distribute independently, and API key isolation prevents cross-project access. 2. Agent event pipeline (4 tests): WAF events flow from node API to analytics storage with correct stats, metrics feed SLO computation with error budget tracking, and alert rules fire when thresholds are exceeded. 3. Health gate rollouts (3 tests): error rate, latency, and heartbeat status gates block rollout progression when nodes are unhealthy, and fixing the node allows the rollout to resume and complete.
1 parent f4395cb commit 50389f7

3 files changed

Lines changed: 1056 additions & 0 deletions

File tree

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
defmodule ZentinelCpWeb.Integration.Api.AgentEventPipelineTest do
2+
@moduledoc """
3+
Integration test proving the full observability pipeline:
4+
5+
WAF events and metrics flow from node API → analytics storage →
6+
SLO computation → alert firing.
7+
"""
8+
use ZentinelCpWeb.IntegrationCase
9+
10+
alias ZentinelCp.{Analytics, Nodes, Observability}
11+
alias ZentinelCp.Observability.AlertEvaluator
12+
13+
@moduletag :integration
14+
15+
describe "observability pipeline: WAF events → metrics → SLOs → alerts" do
16+
setup %{conn: conn} do
17+
{api_conn, context} =
18+
setup_api_context(conn,
19+
scopes: [
20+
"nodes:read",
21+
"nodes:write",
22+
"analytics:read",
23+
"analytics:write"
24+
]
25+
)
26+
27+
project_slug = context.project.slug
28+
29+
# Register a node via API
30+
register_resp =
31+
conn
32+
|> put_req_header("content-type", "application/json")
33+
|> post("/api/v1/projects/#{project_slug}/nodes/register", %{
34+
name: "observability-node",
35+
labels: %{"env" => "prod"},
36+
capabilities: ["proxy"],
37+
version: "1.0.0"
38+
})
39+
|> json_response!(201)
40+
41+
node_id = register_resp["node_id"]
42+
node_key = register_resp["node_key"]
43+
44+
# Send initial heartbeat so the node is online
45+
Phoenix.ConnTest.build_conn()
46+
|> authenticate_as_node(node_key)
47+
|> post("/api/v1/nodes/#{node_id}/heartbeat", %{
48+
health: %{"status" => "healthy"},
49+
metrics: %{"cpu_percent" => 20, "memory_percent" => 35},
50+
version: "1.0.0"
51+
})
52+
|> json_response!(200)
53+
54+
# Create a service for metrics association
55+
service = ZentinelCp.ServicesFixtures.service_fixture(%{project: context.project})
56+
57+
%{
58+
api_conn: api_conn,
59+
context: context,
60+
node_id: node_id,
61+
node_key: node_key,
62+
service: service,
63+
raw_conn: conn
64+
}
65+
end
66+
67+
test "WAF events flow from node to analytics", %{
68+
context: context,
69+
node_id: node_id,
70+
node_key: node_key,
71+
raw_conn: conn
72+
} do
73+
project = context.project
74+
75+
# Send WAF events via the node-authenticated endpoint
76+
waf_resp =
77+
Phoenix.ConnTest.build_conn()
78+
|> authenticate_as_node(node_key)
79+
|> post("/api/v1/nodes/#{node_id}/waf-events", %{
80+
events: [
81+
%{
82+
rule_type: "sqli",
83+
rule_id: "CRS-942100",
84+
action: "blocked",
85+
severity: "high",
86+
client_ip: "10.0.0.99",
87+
method: "POST",
88+
path: "/api/login",
89+
matched_data: "' OR 1=1 --",
90+
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
91+
},
92+
%{
93+
rule_type: "xss",
94+
rule_id: "CRS-941100",
95+
action: "logged",
96+
severity: "medium",
97+
client_ip: "10.0.0.50",
98+
method: "GET",
99+
path: "/search",
100+
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
101+
}
102+
]
103+
})
104+
|> json_response!(200)
105+
106+
assert waf_resp["events_ingested"] == 2
107+
108+
# Verify events are stored in analytics
109+
events = Analytics.list_waf_events(project.id, time_range: 1)
110+
assert length(events) == 2
111+
112+
rule_types = Enum.map(events, & &1.rule_type)
113+
assert "sqli" in rule_types
114+
assert "xss" in rule_types
115+
116+
actions = Enum.map(events, & &1.action)
117+
assert "blocked" in actions
118+
assert "logged" in actions
119+
120+
# Verify aggregated stats
121+
stats = Analytics.get_waf_event_stats(project.id, 1)
122+
assert stats.total >= 2
123+
assert stats.blocked >= 1
124+
assert stats.unique_ips >= 1
125+
126+
# Verify top blocked IPs
127+
top_ips = Analytics.get_top_blocked_ips(project.id, 1)
128+
blocked_ips = Enum.map(top_ips, fn {ip, _count} -> ip end)
129+
assert "10.0.0.99" in blocked_ips
130+
end
131+
132+
test "metrics ingestion populates service metrics", %{
133+
context: context,
134+
node_id: node_id,
135+
node_key: node_key,
136+
service: service
137+
} do
138+
project = context.project
139+
140+
# Send metrics via the node-authenticated endpoint
141+
metrics_resp =
142+
Phoenix.ConnTest.build_conn()
143+
|> authenticate_as_node(node_key)
144+
|> post("/api/v1/nodes/#{node_id}/metrics", %{
145+
metrics: [
146+
%{
147+
service_id: service.id,
148+
project_id: project.id,
149+
period_start: DateTime.utc_now() |> DateTime.to_iso8601(),
150+
period_seconds: 60,
151+
request_count: 1000,
152+
error_count: 10,
153+
latency_p50_ms: 25,
154+
latency_p95_ms: 100,
155+
latency_p99_ms: 200,
156+
status_2xx: 950,
157+
status_3xx: 20,
158+
status_4xx: 20,
159+
status_5xx: 10
160+
}
161+
]
162+
})
163+
|> json_response!(200)
164+
165+
assert metrics_resp["metrics_ingested"] == 1
166+
167+
# Verify metrics are queryable
168+
project_metrics = Analytics.get_project_metrics(project.id, 1)
169+
assert project_metrics.total_requests >= 1000
170+
assert project_metrics.total_5xx >= 10
171+
end
172+
173+
test "SLO computation reflects ingested metrics", %{
174+
context: context,
175+
service: service
176+
} do
177+
project = context.project
178+
179+
# Ingest metrics directly for simplicity
180+
Analytics.ingest_metrics([
181+
%{
182+
"service_id" => service.id,
183+
"project_id" => project.id,
184+
"period_start" => DateTime.utc_now() |> DateTime.to_iso8601(),
185+
"period_seconds" => 60,
186+
"request_count" => 1000,
187+
"error_count" => 10,
188+
"status_2xx" => 950,
189+
"status_3xx" => 20,
190+
"status_4xx" => 20,
191+
"status_5xx" => 10
192+
}
193+
])
194+
195+
# Create an SLO targeting availability
196+
{:ok, slo} =
197+
Observability.create_slo(%{
198+
project_id: project.id,
199+
service_id: service.id,
200+
name: "API Availability",
201+
sli_type: "availability",
202+
target: 99.0,
203+
window_days: 1
204+
})
205+
206+
# Compute the SLI from ingested metrics
207+
{:ok, computed} = Observability.compute_sli(slo)
208+
209+
assert computed.error_budget_remaining != nil
210+
assert computed.burn_rate != nil
211+
assert computed.last_computed_at != nil
212+
end
213+
214+
test "alert rule fires on high error rate from ingested metrics", %{
215+
context: context,
216+
service: service
217+
} do
218+
project = context.project
219+
220+
# Ingest metrics with a high error rate (20%)
221+
Analytics.ingest_metrics([
222+
%{
223+
"service_id" => service.id,
224+
"project_id" => project.id,
225+
"period_start" => DateTime.utc_now() |> DateTime.to_iso8601(),
226+
"period_seconds" => 60,
227+
"request_count" => 100,
228+
"error_count" => 20,
229+
"status_2xx" => 80,
230+
"status_5xx" => 20
231+
}
232+
])
233+
234+
# Create an alert rule that fires when error_rate > 10%
235+
{:ok, rule} =
236+
Observability.create_alert_rule(%{
237+
project_id: project.id,
238+
name: "High Error Rate",
239+
rule_type: "metric",
240+
severity: "critical",
241+
for_seconds: 0,
242+
condition: %{
243+
"metric" => "error_rate",
244+
"operator" => ">",
245+
"value" => 10.0,
246+
"window_minutes" => 5
247+
}
248+
})
249+
250+
# Evaluate the rule against the ingested data
251+
{:ok, _} = AlertEvaluator.evaluate_rule(rule)
252+
253+
# Verify that the alert is firing
254+
alert_states = Observability.active_alert_states(rule.id)
255+
assert length(alert_states) >= 1
256+
257+
firing = hd(alert_states)
258+
assert firing.value > 10.0
259+
end
260+
end
261+
end

0 commit comments

Comments
 (0)