Files
tiny-forge/internal/api/event_triggers_test.go
T
alexei.dolgolyov 7a9ff7ad54 feat(observability): event triggers + log scanner backend
Two paired backends sharing the events.Bus seam:

Event triggers (consumer-side):
- internal/store/event_triggers.go — CRUD with action_secret
  redaction on read (placeholder echo treated as "no change" on
  PATCH so secrets aren't accidentally wiped).
- internal/events/dispatcher.go — bus subscriber, AND-composed
  filters (severity CSV, source CSV, message regex with memoized
  compile cache). Structural loop-prevention: never writes to
  event_log. Sends via notifier.SendPayload.
- internal/notify: SendPayload + SendSyncForTestPayload methods,
  TierEventTrigger constant, doSendRaw shared with the legacy
  Event-shaped path.
- internal/api/event_triggers.go — admin-gated CRUD + /test
  sending the real TriggerWebhookPayload shape. SSRF guard
  rejects loopback / link-local / unspecified targets. PATCH
  uses pointer-typed DTO for partial updates.

Log scanner (producer-side):
- internal/logscanner/ — engine (per-rule cooldown +
  per-container token bucket, atomic drop counters), tail
  (multiplexed docker frame demuxer with TTY fallback + 16 MiB
  payload cap + 1 MiB reassembly cap + RFC3339Nano-validated
  timestamp strip + UTF-8-safe message truncation), manager
  (5s container polling, atomic.Pointer[Snapshot] hot-reload,
  HitEmitter writes event_log + publishes EventLog so the
  trigger dispatcher picks them up immediately).
- internal/docker/container.go — ContainerLogsOpts exposes
  stream selection for stderr-only / stdout-only rules.
- internal/store: log_scan_rules table + CRUD with
  EffectiveLogScanRules resolver (globals minus per-workload
  overrides plus workload-only additions). Transactional
  cascade-delete of overrides when a global rule is removed.
- internal/api/log_scan_rules.go — admin-gated CRUD + /test
  (sample_line → matched/captures) + /stats (drop counters +
  active tail count + last-snapshot compile errors) +
  GET /api/workloads/{id}/effective-rules.

cmd/server/main.go wires both subsystems next to the existing
RegisterPersistentLogger. Coverage spans engine cooldown / bucket
counter tests, snapshot effective-set semantics, manager compile-
error capture, dispatcher matching, store validation +
cascade-delete, API URL validator + secret redaction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:18:11 +03:00

144 lines
3.8 KiB
Go

package api
import (
"strings"
"testing"
"github.com/alexei/tinyforge/internal/store"
)
func TestValidateWebhookURL(t *testing.T) {
cases := []struct {
name string
url string
wantErr string // substring; empty = pass
}{
{"https valid", "https://example.com/hook", ""},
{"http valid", "http://example.com:8080/hook", ""},
{"RFC1918 private LAN allowed", "http://192.168.1.50:9090/hook", ""},
{"loopback rejected", "http://127.0.0.1:8090/hook", "loopback"},
{"ipv6 loopback rejected", "http://[::1]:9000/hook", "loopback"},
{"link-local rejected", "http://169.254.169.254/latest/meta-data", "reserved"},
{"unspecified rejected", "http://0.0.0.0:9000/hook", "reserved"},
{"file scheme rejected", "file:///etc/passwd", "http:// or https://"},
{"missing host rejected", "https://", "missing host"},
{"malformed url rejected", "://nope", "invalid URL"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := validateWebhookURL(c.url)
if c.wantErr == "" {
if got != "" {
t.Fatalf("expected pass, got error: %q", got)
}
return
}
if !strings.Contains(got, c.wantErr) {
t.Fatalf("error mismatch:\n got: %q\n want substring: %q", got, c.wantErr)
}
})
}
}
func TestValidateTrigger(t *testing.T) {
cases := []struct {
name string
in store.EventTrigger
want string // substring of error; empty = pass
}{
{
name: "missing name",
in: store.EventTrigger{ActionTarget: "https://x.example.com/h"},
want: "name is required",
},
{
name: "missing target",
in: store.EventTrigger{Name: "n"},
want: "action_target is required",
},
{
name: "bad scheme",
in: store.EventTrigger{Name: "n", ActionTarget: "ftp://x.example.com/h"},
want: "http:// or https://",
},
{
name: "loopback target",
in: store.EventTrigger{Name: "n", ActionTarget: "http://127.0.0.1/hook"},
want: "loopback",
},
{
name: "unsupported action_type",
in: store.EventTrigger{Name: "n", ActionType: "email", ActionTarget: "https://x.example.com/h"},
want: "action_type must be",
},
{
name: "invalid regex",
in: store.EventTrigger{
Name: "n", ActionTarget: "https://x.example.com/h",
FilterMessageRegex: "([unclosed",
},
want: "filter_message_regex invalid",
},
{
name: "all valid",
in: store.EventTrigger{
Name: "n",
ActionTarget: "https://x.example.com/h",
FilterSeverity: "warn,error",
FilterMessageRegex: `\bpanic\b`,
},
want: "",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := validateTrigger(c.in)
if c.want == "" {
if got != "" {
t.Fatalf("expected pass, got error: %q", got)
}
return
}
if !strings.Contains(got, c.want) {
t.Fatalf("error mismatch:\n got: %q\n want substring: %q", got, c.want)
}
})
}
}
func TestRedactTriggerSecret(t *testing.T) {
withSecret := store.EventTrigger{Name: "n", ActionSecret: "shh-real-secret"}
got := redactTriggerSecret(withSecret)
if got.ActionSecret != actionSecretPlaceholder {
t.Errorf("expected placeholder, got %q", got.ActionSecret)
}
if withSecret.ActionSecret != "shh-real-secret" {
t.Errorf("original mutated: %q", withSecret.ActionSecret)
}
noSecret := store.EventTrigger{Name: "n", ActionSecret: ""}
got2 := redactTriggerSecret(noSecret)
if got2.ActionSecret != "" {
t.Errorf("empty secret should stay empty, got %q", got2.ActionSecret)
}
}
func TestDerefString(t *testing.T) {
if derefString(nil) != "" {
t.Error("nil should deref to empty string")
}
s := "value"
if derefString(&s) != "value" {
t.Error("non-nil should deref to value")
}
}
func TestFirstNonEmpty(t *testing.T) {
if firstNonEmpty("a", "b") != "a" {
t.Error("non-empty first wins")
}
if firstNonEmpty("", "b") != "b" {
t.Error("fallback when first empty")
}
}