abc81e60c8
Single Go binary with embedded Bootstrap 3 + jQuery UI, querying logs.raw over the ClickHouse HTTP interface as the readonly logreader user. Runs behind oauth2-proxy; the app does no auth itself. Server-side enforced time bounds (15m default, 72h max), parameterized queries, raw-SQL WHERE fragment wrapped with enforced bounds and LIMIT, tail polling with a clamped cursor, facets, healthz. Woodpecker build/test plus tag-driven image push to artifactapi docker-internal.
397 lines
12 KiB
Go
397 lines
12 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
var testNow = time.Date(2026, 8, 23, 12, 0, 0, 0, time.UTC)
|
|
|
|
// mockCH captures the SQL body and bound parameters of each ClickHouse HTTP
|
|
// request and returns a canned FORMAT JSON response.
|
|
type mockCH struct {
|
|
*httptest.Server
|
|
lastSQL string
|
|
lastParams url.Values
|
|
respond func() (int, string)
|
|
}
|
|
|
|
func newMockCH(t *testing.T) *mockCH {
|
|
t.Helper()
|
|
m := &mockCH{respond: func() (int, string) { return 200, `{"meta":[],"data":[],"rows":0}` }}
|
|
m.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
m.lastSQL = string(body)
|
|
m.lastParams = r.URL.Query()
|
|
code, resp := m.respond()
|
|
w.WriteHeader(code)
|
|
w.Write([]byte(resp))
|
|
}))
|
|
t.Cleanup(m.Close)
|
|
return m
|
|
}
|
|
|
|
func newTestServer(t *testing.T) (*Server, *mockCH) {
|
|
t.Helper()
|
|
ch := newMockCH(t)
|
|
s := New(Config{CHURL: ch.URL, CHUser: "logreader", CHPassword: "x", Version: "test"})
|
|
s.now = func() time.Time { return testNow }
|
|
return s, ch
|
|
}
|
|
|
|
func get(t *testing.T, s *Server, path string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
rec := httptest.NewRecorder()
|
|
s.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func boundMS(t *testing.T, params url.Values, name string) int64 {
|
|
t.Helper()
|
|
n, err := strconv.ParseInt(params.Get("param_"+name), 10, 64)
|
|
if err != nil {
|
|
t.Fatalf("param %s not an int64: %v", name, err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// --- time bound enforcement ---
|
|
|
|
func TestQueryDefaultWindow(t *testing.T) {
|
|
s, ch := newTestServer(t)
|
|
rec := get(t, s, "/api/query")
|
|
if rec.Code != 200 {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
|
}
|
|
since := boundMS(t, ch.lastParams, "since_ms")
|
|
until := boundMS(t, ch.lastParams, "until_ms")
|
|
if until != testNow.UnixMilli() {
|
|
t.Errorf("until = %d, want now (%d)", until, testNow.UnixMilli())
|
|
}
|
|
if until-since != defaultWindow.Milliseconds() {
|
|
t.Errorf("window = %dms, want default %dms", until-since, defaultWindow.Milliseconds())
|
|
}
|
|
for _, pred := range []string{
|
|
"timestamp >= fromUnixTimestamp64Milli({since_ms:Int64})",
|
|
"timestamp < fromUnixTimestamp64Milli({until_ms:Int64})",
|
|
} {
|
|
if !strings.Contains(ch.lastSQL, pred) {
|
|
t.Errorf("SQL missing enforced bound %q:\n%s", pred, ch.lastSQL)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestQueryExplicitWindow(t *testing.T) {
|
|
s, ch := newTestServer(t)
|
|
rec := get(t, s, "/api/query?since=2026-08-23T10:00:00Z&until=2026-08-23T11:00:00Z")
|
|
if rec.Code != 200 {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
|
}
|
|
since := boundMS(t, ch.lastParams, "since_ms")
|
|
until := boundMS(t, ch.lastParams, "until_ms")
|
|
if until-since != time.Hour.Milliseconds() {
|
|
t.Errorf("window = %dms, want 1h", until-since)
|
|
}
|
|
}
|
|
|
|
func TestQueryRelativeSince(t *testing.T) {
|
|
s, ch := newTestServer(t)
|
|
if rec := get(t, s, "/api/query?since=6h"); rec.Code != 200 {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
|
}
|
|
since := boundMS(t, ch.lastParams, "since_ms")
|
|
if want := testNow.Add(-6 * time.Hour).UnixMilli(); since != want {
|
|
t.Errorf("since = %d, want %d", since, want)
|
|
}
|
|
}
|
|
|
|
func TestQueryRejectsUnboundedWindow(t *testing.T) {
|
|
s, _ := newTestServer(t)
|
|
cases := []string{
|
|
"/api/query?since=1970-01-01T00:00:00Z", // wider than maxWindow
|
|
"/api/query?since=2026-08-01T00:00:00Z&until=30d", // nonsense + wide
|
|
"/api/query?since=0", // epoch
|
|
}
|
|
for _, c := range cases {
|
|
if rec := get(t, s, c); rec.Code != http.StatusBadRequest {
|
|
t.Errorf("%s: status %d, want 400 (body %s)", c, rec.Code, rec.Body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestQueryRejectsInvertedAndBadTimes(t *testing.T) {
|
|
s, _ := newTestServer(t)
|
|
cases := []string{
|
|
"/api/query?since=2026-08-23T11:00:00Z&until=2026-08-23T10:00:00Z",
|
|
"/api/query?since=yesterdayish",
|
|
"/api/query?until=not-a-time",
|
|
}
|
|
for _, c := range cases {
|
|
if rec := get(t, s, c); rec.Code != http.StatusBadRequest {
|
|
t.Errorf("%s: status %d, want 400", c, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- filters and fuzzy search ---
|
|
|
|
func TestQueryFilterParamsAreBound(t *testing.T) {
|
|
s, ch := newTestServer(t)
|
|
rec := get(t, s, "/api/query?namespace=prod&app=encapi&severity=error")
|
|
if rec.Code != 200 {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
|
}
|
|
if got := ch.lastParams.Get("param_f_namespace"); got != "prod" {
|
|
t.Errorf("param_f_namespace = %q", got)
|
|
}
|
|
if got := ch.lastParams.Get("param_f_app"); got != "encapi" {
|
|
t.Errorf("param_f_app = %q", got)
|
|
}
|
|
for _, pred := range []string{
|
|
"namespace = {f_namespace:String}",
|
|
"labels['app'] = {f_app:String}",
|
|
"severity = {f_severity:String}",
|
|
} {
|
|
if !strings.Contains(ch.lastSQL, pred) {
|
|
t.Errorf("SQL missing %q:\n%s", pred, ch.lastSQL)
|
|
}
|
|
}
|
|
// values must never be interpolated into the SQL text
|
|
if strings.Contains(ch.lastSQL, "prod") || strings.Contains(ch.lastSQL, "encapi") {
|
|
t.Errorf("filter value interpolated into SQL:\n%s", ch.lastSQL)
|
|
}
|
|
}
|
|
|
|
func TestQueryFuzzyTokens(t *testing.T) {
|
|
s, ch := newTestServer(t)
|
|
rec := get(t, s, "/api/query?q="+url.QueryEscape("timeout o'brien"))
|
|
if rec.Code != 200 {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
|
}
|
|
if got, want := ch.lastParams.Get("param_q_tokens"), `['timeout','o\'brien']`; got != want {
|
|
t.Errorf("q_tokens = %q, want %q", got, want)
|
|
}
|
|
if !strings.Contains(ch.lastSQL, "arrayAll(t -> positionCaseInsensitive(message, t) > 0, {q_tokens:Array(String)})") {
|
|
t.Errorf("SQL missing fuzzy predicate:\n%s", ch.lastSQL)
|
|
}
|
|
}
|
|
|
|
// --- raw sql fragment ---
|
|
|
|
func TestQuerySQLFragmentWrapped(t *testing.T) {
|
|
s, ch := newTestServer(t)
|
|
frag := "severity = 'error' AND message LIKE '%oom%'"
|
|
rec := get(t, s, "/api/query?sql="+url.QueryEscape(frag))
|
|
if rec.Code != 200 {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
|
}
|
|
if !strings.Contains(ch.lastSQL, "AND ( "+frag+" )") {
|
|
t.Errorf("fragment not parenthesised inside WHERE:\n%s", ch.lastSQL)
|
|
}
|
|
// bounds and LIMIT still enforced around the fragment
|
|
if !strings.Contains(ch.lastSQL, "timestamp >= fromUnixTimestamp64Milli({since_ms:Int64})") {
|
|
t.Errorf("time bounds missing with sql fragment:\n%s", ch.lastSQL)
|
|
}
|
|
if !strings.Contains(ch.lastSQL, "LIMIT 100 OFFSET 0") {
|
|
t.Errorf("LIMIT missing with sql fragment:\n%s", ch.lastSQL)
|
|
}
|
|
}
|
|
|
|
func TestQuerySQLFragmentRejectsSemicolon(t *testing.T) {
|
|
s, _ := newTestServer(t)
|
|
rec := get(t, s, "/api/query?sql="+url.QueryEscape("1=1; DROP TABLE logs.raw"))
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status %d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
// --- limit / offset ---
|
|
|
|
func TestQueryLimitCappedAndOffset(t *testing.T) {
|
|
s, ch := newTestServer(t)
|
|
rec := get(t, s, "/api/query?limit=999999&offset=200")
|
|
if rec.Code != 200 {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
|
}
|
|
if !strings.Contains(ch.lastSQL, "LIMIT 1000 OFFSET 200") {
|
|
t.Errorf("limit not capped at %d:\n%s", maxLimit, ch.lastSQL)
|
|
}
|
|
for _, c := range []string{"limit=0", "limit=-5", "limit=abc", "offset=-1"} {
|
|
if rec := get(t, s, "/api/query?"+c); rec.Code != http.StatusBadRequest {
|
|
t.Errorf("%s: status %d, want 400", c, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- tail ---
|
|
|
|
func tailRows(ts ...int64) string {
|
|
rows := make([]map[string]any, len(ts))
|
|
for i, v := range ts {
|
|
rows[i] = map[string]any{"ts_ms": v, "message": "m"}
|
|
}
|
|
b, _ := json.Marshal(map[string]any{"meta": []any{}, "data": rows, "rows": len(rows)})
|
|
return string(b)
|
|
}
|
|
|
|
func TestTailCursorAdvances(t *testing.T) {
|
|
s, ch := newTestServer(t)
|
|
cursor := testNow.Add(-5 * time.Second).UnixMilli()
|
|
ch.respond = func() (int, string) { return 200, tailRows(cursor+100, cursor+250) }
|
|
|
|
rec := get(t, s, "/api/tail?cursor="+strconv.FormatInt(cursor, 10))
|
|
if rec.Code != 200 {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
|
}
|
|
if got := boundMS(t, ch.lastParams, "since_ms"); got != cursor {
|
|
t.Errorf("since_ms = %d, want cursor %d", got, cursor)
|
|
}
|
|
if !strings.Contains(ch.lastSQL, "timestamp > fromUnixTimestamp64Milli({since_ms:Int64})") {
|
|
t.Errorf("tail lower bound must be exclusive:\n%s", ch.lastSQL)
|
|
}
|
|
if !strings.Contains(ch.lastSQL, "ORDER BY timestamp ASC") {
|
|
t.Errorf("tail must be ascending:\n%s", ch.lastSQL)
|
|
}
|
|
var resp struct {
|
|
Cursor int64 `json:"cursor"`
|
|
Count int `json:"count"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.Cursor != cursor+250 {
|
|
t.Errorf("cursor = %d, want %d", resp.Cursor, cursor+250)
|
|
}
|
|
if resp.Count != 2 {
|
|
t.Errorf("count = %d, want 2", resp.Count)
|
|
}
|
|
}
|
|
|
|
func TestTailCursorUnchangedWhenNoRows(t *testing.T) {
|
|
s, _ := newTestServer(t)
|
|
cursor := testNow.Add(-3 * time.Second).UnixMilli()
|
|
rec := get(t, s, "/api/tail?cursor="+strconv.FormatInt(cursor, 10))
|
|
if rec.Code != 200 {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
|
}
|
|
var resp struct {
|
|
Cursor int64 `json:"cursor"`
|
|
}
|
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
|
if resp.Cursor != cursor {
|
|
t.Errorf("cursor = %d, want unchanged %d", resp.Cursor, cursor)
|
|
}
|
|
}
|
|
|
|
func TestTailStaleCursorClamped(t *testing.T) {
|
|
s, ch := newTestServer(t)
|
|
stale := testNow.Add(-48 * time.Hour).UnixMilli()
|
|
rec := get(t, s, "/api/tail?cursor="+strconv.FormatInt(stale, 10))
|
|
if rec.Code != 200 {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
|
}
|
|
if got, min := boundMS(t, ch.lastParams, "since_ms"), testNow.Add(-defaultWindow).UnixMilli(); got != min {
|
|
t.Errorf("stale cursor not clamped: since_ms = %d, want %d", got, min)
|
|
}
|
|
}
|
|
|
|
func TestTailBadCursor(t *testing.T) {
|
|
s, _ := newTestServer(t)
|
|
if rec := get(t, s, "/api/tail?cursor=nope"); rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status %d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
// --- facets ---
|
|
|
|
func TestFacets(t *testing.T) {
|
|
s, ch := newTestServer(t)
|
|
ch.respond = func() (int, string) {
|
|
rows := []map[string]any{
|
|
{"facet": "namespace", "value": "prod", "n": 100},
|
|
{"facet": "namespace", "value": "logging", "n": 50},
|
|
{"facet": "app", "value": "encapi", "n": 70},
|
|
{"facet": "host", "value": "node1", "n": 30},
|
|
}
|
|
b, _ := json.Marshal(map[string]any{"meta": []any{}, "data": rows, "rows": len(rows)})
|
|
return 200, string(b)
|
|
}
|
|
rec := get(t, s, "/api/facets?since=1h")
|
|
if rec.Code != 200 {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
|
}
|
|
for _, expr := range []string{"GROUP BY value", "labels['app']", "UNION ALL", "LIMIT 20"} {
|
|
if !strings.Contains(ch.lastSQL, expr) {
|
|
t.Errorf("facets SQL missing %q:\n%s", expr, ch.lastSQL)
|
|
}
|
|
}
|
|
var resp map[string][]map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(resp["namespaces"]) != 2 || len(resp["apps"]) != 1 || len(resp["hosts"]) != 1 {
|
|
t.Errorf("facet grouping wrong: %v", resp)
|
|
}
|
|
if resp["namespaces"][0]["value"] != "prod" {
|
|
t.Errorf("namespaces[0] = %v", resp["namespaces"][0])
|
|
}
|
|
}
|
|
|
|
func TestFacetsRejectsWideWindow(t *testing.T) {
|
|
s, _ := newTestServer(t)
|
|
if rec := get(t, s, "/api/facets?since=2020-01-01T00:00:00Z"); rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status %d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
// --- healthz + plumbing ---
|
|
|
|
func TestHealthz(t *testing.T) {
|
|
s, ch := newTestServer(t)
|
|
if rec := get(t, s, "/healthz"); rec.Code != 200 {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
|
}
|
|
ch.respond = func() (int, string) { return 500, "boom" }
|
|
if rec := get(t, s, "/healthz"); rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("status %d, want 503", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestQueryPropagatesCHError(t *testing.T) {
|
|
s, ch := newTestServer(t)
|
|
ch.respond = func() (int, string) { return 400, "Code: 62. DB::Exception: Syntax error" }
|
|
rec := get(t, s, "/api/query?sql="+url.QueryEscape("not valid sql"))
|
|
if rec.Code != http.StatusBadGateway {
|
|
t.Fatalf("status %d, want 502", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Syntax error") {
|
|
t.Errorf("CH error not surfaced: %s", rec.Body)
|
|
}
|
|
}
|
|
|
|
func TestMaxExecutionTimeSet(t *testing.T) {
|
|
s, ch := newTestServer(t)
|
|
if rec := get(t, s, "/api/query"); rec.Code != 200 {
|
|
t.Fatalf("status %d", rec.Code)
|
|
}
|
|
if got := ch.lastParams.Get("max_execution_time"); got != "30" {
|
|
t.Errorf("max_execution_time = %q, want 30", got)
|
|
}
|
|
}
|
|
|
|
func TestIndexServed(t *testing.T) {
|
|
s, _ := newTestServer(t)
|
|
rec := get(t, s, "/")
|
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "logviewer") {
|
|
t.Fatalf("index not served: %d", rec.Code)
|
|
}
|
|
}
|