7a9ff7ad54
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>
144 lines
3.8 KiB
Go
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")
|
|
}
|
|
}
|