415bf0cce1
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.
205 lines
5.6 KiB
Go
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")
|
|
}
|
|
}
|