Every Historical and Advisory answer stated a window in AEST and queried one
shifted by ten hours.
rolling_window() builds its boundary strings in SITE_TIMEZONE - that is the
whole point of it, and its docstring says so. metrics.run() then posted the
query to Cube with no timezone at all, and Cube defaults to UTC. So
"2026-08-14T15:22:13" meant 15:22 Sydney to the code that produced it and 15:22
UTC to the engine that ran it, and MetricResult.time_window reported
SITE_TIMEZONE from config rather than whatever the query actually used, so the
two could not disagree visibly.
Measured on the fixtures, same dateRange, one field changed:
timezone UTC 8019 samples
timezone Australia/Sydney 8619 samples
600 samples. One per minute, ten hours, exactly the offset.
Nothing about the answer looked wrong. The prose was right, the count was a
real count, the window description was correctly formatted and correctly named
AEST. It was only visible by reading the Cube query in the UI's "show working"
panel - which is an argument for that panel existing, and an argument for
looking at the thing in a browser rather than trusting curl against the API.
- check_cube_query() now takes site_timezone and pins it onto the query, at
the single point every Cube query passes through. Per-query-builder is the
wrong place: "remember to set the timezone" is not a control, and this
defect is what forgetting looks like. An explicit timezone already on the
query is left alone.
- time_window now reports capped["timezone"] - the timezone the query ran in,
not the one it should have run in.
An unpinned timezone belongs in the same guardrail as an unpinned date range,
and for the same reason: both make an answer unreproducible. The difference is
that an unpinned date range is obvious in the query and an unpinned timezone
is invisible.
eval case H28 records it. Two unit tests: the timezone is pinned, and an
explicit one is not overridden.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
123 lines
3.8 KiB
Python
123 lines
3.8 KiB
Python
"""SQL allow-list and Cube query caps.
|
|
|
|
The Phase 8 gate demands zero SQL executed outside the allow-list. That is a
|
|
property of this module, so it is tested here rather than inferred from the
|
|
eval run.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from guardrails import GuardrailViolation, check_cube_query, check_sql
|
|
|
|
MAX_ROWS = 5000
|
|
|
|
|
|
def test_plain_select_is_allowed_and_capped():
|
|
out = check_sql("SELECT tag_id FROM tags", max_rows=MAX_ROWS)
|
|
assert "LIMIT 5000" in out.upper()
|
|
|
|
|
|
def test_smaller_limit_is_honoured():
|
|
out = check_sql("SELECT tag_id FROM tags LIMIT 10", max_rows=MAX_ROWS)
|
|
assert "LIMIT 10" in out.upper()
|
|
|
|
|
|
def test_larger_limit_is_capped():
|
|
out = check_sql("SELECT tag_id FROM tags LIMIT 999999", max_rows=MAX_ROWS)
|
|
assert "LIMIT 5000" in out.upper()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"sql",
|
|
[
|
|
"INSERT INTO equipment VALUES ('X')",
|
|
"UPDATE tags SET display_name = 'x'",
|
|
"DELETE FROM doc_chunks",
|
|
"DROP TABLE tags",
|
|
"CREATE TABLE t (i int)",
|
|
],
|
|
)
|
|
def test_writes_are_refused(sql):
|
|
with pytest.raises(GuardrailViolation):
|
|
check_sql(sql, max_rows=MAX_ROWS)
|
|
|
|
|
|
def test_stacked_statements_are_refused():
|
|
with pytest.raises(GuardrailViolation) as caught:
|
|
check_sql("SELECT 1 FROM tags; DROP TABLE tags", max_rows=MAX_ROWS)
|
|
assert caught.value.rule in {"multiple_statements", "write_operation"}
|
|
|
|
|
|
def test_table_outside_the_allow_list_is_refused():
|
|
with pytest.raises(GuardrailViolation) as caught:
|
|
check_sql("SELECT * FROM pg_shadow", max_rows=MAX_ROWS)
|
|
assert caught.value.rule == "table_not_allowed"
|
|
|
|
|
|
def test_subquery_tables_are_checked_too():
|
|
with pytest.raises(GuardrailViolation):
|
|
check_sql(
|
|
"SELECT * FROM tags WHERE tag_id IN (SELECT usename FROM pg_shadow)",
|
|
max_rows=MAX_ROWS,
|
|
)
|
|
|
|
|
|
def test_cube_query_needs_a_pinned_window():
|
|
with pytest.raises(GuardrailViolation) as caught:
|
|
check_cube_query({"measures": ["alarm_activity.alarm_count"]}, max_rows=MAX_ROWS)
|
|
assert caught.value.rule == "unpinned_time_window"
|
|
|
|
|
|
def test_cube_query_needs_a_date_range():
|
|
with pytest.raises(GuardrailViolation):
|
|
check_cube_query(
|
|
{"timeDimensions": [{"dimension": "alarm_activity.event_time"}]},
|
|
max_rows=MAX_ROWS,
|
|
)
|
|
|
|
|
|
def test_cube_query_is_capped():
|
|
out = check_cube_query(
|
|
{
|
|
"timeDimensions": [
|
|
{"dimension": "alarm_activity.event_time",
|
|
"dateRange": ["2026-08-13", "2026-08-20"]}
|
|
],
|
|
"limit": 1_000_000,
|
|
},
|
|
max_rows=MAX_ROWS,
|
|
)
|
|
assert out["limit"] == MAX_ROWS
|
|
|
|
|
|
def test_cube_query_gets_the_site_timezone_pinned():
|
|
"""A Cube query without a timezone runs in UTC while the answer says AEST.
|
|
|
|
rolling_window() produces boundary strings in site local time. Cube parses
|
|
a query with no timezone as UTC, so the window queried was shifted by the
|
|
site's UTC offset - ten hours - and nothing in the answer said so. Found by
|
|
reading the 'show working' panel in a browser, not by any test.
|
|
"""
|
|
query = {
|
|
"measures": ["alarms.alarm_count"],
|
|
"timeDimensions": [
|
|
{
|
|
"dimension": "alarms.event_time",
|
|
"dateRange": ["2026-08-14T15:20:16", "2026-08-21T15:20:16"],
|
|
}
|
|
],
|
|
}
|
|
capped = check_cube_query(query, max_rows=5000, site_timezone="Australia/Sydney")
|
|
assert capped["timezone"] == "Australia/Sydney"
|
|
|
|
|
|
def test_an_explicit_timezone_on_the_query_is_not_overridden():
|
|
query = {
|
|
"measures": ["alarms.alarm_count"],
|
|
"timeDimensions": [
|
|
{"dimension": "alarms.event_time", "dateRange": ["2026-08-14", "2026-08-21"]}
|
|
],
|
|
"timezone": "UTC",
|
|
}
|
|
capped = check_cube_query(query, max_rows=5000, site_timezone="Australia/Sydney")
|
|
assert capped["timezone"] == "UTC"
|