Files
clickhouse-tools/internal/chlog/query_test.go
T
unkin-agent 415bf0cce1
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Add chlog CLI with chcat/chtail/chgrep entrypoints
Single Go binary for the ClickHouse log store (logs.raw): chlog with
cat/tail/grep subcommands, plus chcat/chtail/chgrep argv[0]-dispatched
symlink entrypoints. Every query is time-bounded and fully parameterized;
chgrep guards wide unfiltered scans. Ships nfpm RPM with completions and
woodpecker PR/tag pipelines mirroring node-lookup.
2026-08-23 16:43:05 +10:00

205 lines
5.6 KiB
Go

package chlog
import (
"strconv"
"strings"
"testing"
"time"
)
var (
tSince = time.Date(2026, 8, 23, 5, 0, 0, 0, time.UTC)
tUntil = time.Date(2026, 8, 23, 6, 0, 0, 0, time.UTC)
)
func baseFilter() Filter {
return Filter{Since: tSince, Until: tUntil}
}
func TestBuildRequiresBounds(t *testing.T) {
if _, err := Build(Filter{Until: tUntil}); err == nil {
t.Fatal("expected error when since missing")
}
if _, err := Build(Filter{Since: tSince}); err == nil {
t.Fatal("expected error when until missing")
}
if _, err := Build(Filter{Since: tUntil, Until: tSince}); err == nil {
t.Fatal("expected error when since >= until")
}
}
func TestBuildAlwaysTimeBounded(t *testing.T) {
q, err := Build(baseFilter())
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"timestamp >= fromUnixTimestamp64Milli({since_ms:Int64})",
"timestamp < fromUnixTimestamp64Milli({until_ms:Int64})",
"ORDER BY timestamp ASC",
} {
if !strings.Contains(q.SQL, want) {
t.Errorf("SQL missing %q:\n%s", want, q.SQL)
}
}
if q.Params["since_ms"] != strconv.FormatInt(tSince.UnixMilli(), 10) {
t.Errorf("since_ms = %s", q.Params["since_ms"])
}
if q.Params["until_ms"] != strconv.FormatInt(tUntil.UnixMilli(), 10) {
t.Errorf("until_ms = %s", q.Params["until_ms"])
}
}
func TestBuildNoUserInputInSQL(t *testing.T) {
f := baseFilter()
f.Namespace = "evil'; DROP TABLE logs.raw; --"
f.Pattern = "inject{p:String}"
f.App = "x' OR 1=1"
f.Fields = map[string]string{"k'": "v\""}
q, err := Build(f)
if err != nil {
t.Fatal(err)
}
for _, needle := range []string{f.Namespace, f.Pattern, f.App, "k'", "v\"", "DROP"} {
if strings.Contains(q.SQL, needle) {
t.Errorf("user input %q leaked into SQL:\n%s", needle, q.SQL)
}
}
if q.Params["ns"] != f.Namespace || q.Params["pattern"] != f.Pattern {
t.Errorf("params missing user values: %v", q.Params)
}
}
func TestBuildFilters(t *testing.T) {
f := baseFilter()
f.Namespace = "logging"
f.Host = "node1"
f.Pod = "vector-abc"
f.Container = "vector"
f.Stream = "stderr"
f.Source = "k8s"
f.App = "vector"
f.Severity = "ERROR"
f.Limit = 100
q, err := Build(f)
if err != nil {
t.Fatal(err)
}
for clause, param := range map[string]string{
"namespace = {ns:String}": "ns",
"host = {host:String}": "host",
"pod = {pod:String}": "pod",
"container = {container:String}": "container",
"stream = {stream:String}": "stream",
"source = {source:String}": "source",
"labels['app'] = {app:String}": "app",
"lowerUTF8(severity) = {severity:String}": "severity",
"LIMIT {limit:UInt64}": "limit",
} {
if !strings.Contains(q.SQL, clause) {
t.Errorf("SQL missing %q", clause)
}
if _, ok := q.Params[param]; !ok {
t.Errorf("param %q missing", param)
}
}
if q.Params["severity"] != "error" {
t.Errorf("severity not lowercased: %q", q.Params["severity"])
}
if q.Params["limit"] != "100" {
t.Errorf("limit = %q", q.Params["limit"])
}
}
func TestBuildEmptyFiltersOmitted(t *testing.T) {
q, err := Build(baseFilter())
if err != nil {
t.Fatal(err)
}
_, where, ok := strings.Cut(q.SQL, " WHERE ")
if !ok {
t.Fatalf("no WHERE clause:\n%s", q.SQL)
}
for _, clause := range []string{"namespace =", "host =", "pod =", "labels[", "lowerUTF8", "position", "match", "LIMIT"} {
if strings.Contains(where, clause) {
t.Errorf("unexpected clause %q in WHERE:\n%s", clause, where)
}
}
if len(q.Params) != 2 {
t.Errorf("want only time params, got %v", q.Params)
}
}
func TestBuildGrepVariants(t *testing.T) {
cases := []struct {
regex, ignoreCase bool
wantClause string
wantPattern string
}{
{false, false, "position(message, {pattern:String}) > 0", "Timeout"},
{false, true, "positionCaseInsensitive(message, {pattern:String}) > 0", "Timeout"},
{true, false, "match(message, {pattern:String})", "Timeout"},
{true, true, "match(message, {pattern:String})", "(?i)Timeout"},
}
for _, c := range cases {
f := baseFilter()
f.Pattern = "Timeout"
f.Regex = c.regex
f.IgnoreCase = c.ignoreCase
q, err := Build(f)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(q.SQL, c.wantClause) {
t.Errorf("regex=%v i=%v: SQL missing %q:\n%s", c.regex, c.ignoreCase, c.wantClause, q.SQL)
}
if q.Params["pattern"] != c.wantPattern {
t.Errorf("regex=%v i=%v: pattern param = %q, want %q", c.regex, c.ignoreCase, q.Params["pattern"], c.wantPattern)
}
}
}
func TestBuildFieldsDeterministic(t *testing.T) {
f := baseFilter()
f.Fields = map[string]string{"b": "2", "a": "1"}
q1, err := Build(f)
if err != nil {
t.Fatal(err)
}
q2, _ := Build(f)
if q1.SQL != q2.SQL {
t.Error("field clause order not deterministic")
}
if !strings.Contains(q1.SQL, "fields[{fk0:String}] = {fv0:String}") ||
!strings.Contains(q1.SQL, "fields[{fk1:String}] = {fv1:String}") {
t.Errorf("field clauses missing:\n%s", q1.SQL)
}
if q1.Params["fk0"] != "a" || q1.Params["fv0"] != "1" || q1.Params["fk1"] != "b" || q1.Params["fv1"] != "2" {
t.Errorf("field params wrong: %v", q1.Params)
}
}
func TestSelective(t *testing.T) {
f := baseFilter()
if f.Selective() {
t.Error("empty filter should not be selective")
}
for _, set := range []func(*Filter){
func(f *Filter) { f.Namespace = "x" },
func(f *Filter) { f.Host = "x" },
func(f *Filter) { f.App = "x" },
} {
g := baseFilter()
set(&g)
if !g.Selective() {
t.Errorf("filter %+v should be selective", g)
}
}
g := baseFilter()
g.Pod = "x"
g.Container = "x"
if g.Selective() {
t.Error("pod/container alone should not count as selective")
}
}