236 lines
7.3 KiB
Go
236 lines
7.3 KiB
Go
package model
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// The model package shares a single in-memory SQLite across all tests
|
|
// via TestMain in task_cas_test.go. We MUST NOT swap DB out or null
|
|
// it in Cleanup — doing so wedges every other test in the package.
|
|
//
|
|
// Strategy: AutoMigrate the audit table once (idempotent on SQLite),
|
|
// then DELETE rows at the top of each test to isolate cases.
|
|
|
|
func setupAuditTest(t *testing.T) {
|
|
t.Helper()
|
|
if err := DB.AutoMigrate(&AgentAuditEvent{}); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
if err := DB.Exec("DELETE FROM agent_audit_events").Error; err != nil {
|
|
t.Fatalf("truncate: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestInsertAgentAuditEvent_PersistsRow(t *testing.T) {
|
|
// Regression for H1 — the previous implementation appended to an
|
|
// in-process map that was wiped on every container restart. This
|
|
// test pins the new behaviour: rows survive in DB.
|
|
setupAuditTest(t)
|
|
|
|
evt := &AgentAuditEvent{
|
|
EventID: "evt_test_001",
|
|
Event: "deployment.accepted",
|
|
Actor: "agent_control_plane",
|
|
Resource: "dep_abc",
|
|
UserID: "user-42",
|
|
ChannelID: "channel-1",
|
|
BindingScope: "project-main",
|
|
DeploymentID: "dep_abc",
|
|
CorrelationID: "corr-xyz",
|
|
RequestID: "req-001",
|
|
}
|
|
InsertAgentAuditEvent(evt)
|
|
|
|
var got AgentAuditEvent
|
|
if err := DB.Where("event_id = ?", "evt_test_001").First(&got).Error; err != nil {
|
|
t.Fatalf("not persisted: %v", err)
|
|
}
|
|
if got.Result != "ok" {
|
|
t.Errorf("default Result should be 'ok', got %q", got.Result)
|
|
}
|
|
if got.SchemaVersion != 1 {
|
|
t.Errorf("default SchemaVersion should be 1, got %d", got.SchemaVersion)
|
|
}
|
|
if got.OccurredAt == 0 {
|
|
t.Errorf("OccurredAt should be auto-stamped, got 0")
|
|
}
|
|
}
|
|
|
|
func TestInsertAgentAuditEvent_NilGuards(t *testing.T) {
|
|
// Production safety: audit writes run inside hot paths (right
|
|
// after a deployment mutation). A nil DB or nil event MUST NOT
|
|
// panic — better to drop the audit row than to fail the user
|
|
// API call.
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("InsertAgentAuditEvent panicked: %v", r)
|
|
}
|
|
}()
|
|
InsertAgentAuditEvent(nil) // nil evt
|
|
prev := DB
|
|
DB = nil
|
|
InsertAgentAuditEvent(&AgentAuditEvent{EventID: "x"}) // nil DB
|
|
DB = prev
|
|
}
|
|
|
|
func TestListAgentAuditEvents_FilterAndPaginate(t *testing.T) {
|
|
setupAuditTest(t)
|
|
|
|
now := time.Now().UnixMilli()
|
|
for i := 0; i < 5; i++ {
|
|
uid := "user-A"
|
|
if i%2 == 0 {
|
|
uid = "user-B"
|
|
}
|
|
InsertAgentAuditEvent(&AgentAuditEvent{
|
|
EventID: fmt.Sprintf("evt_%d", i),
|
|
Event: "deployment.accepted",
|
|
UserID: uid,
|
|
DeploymentID: "dep_X",
|
|
OccurredAt: now + int64(i)*1000,
|
|
})
|
|
}
|
|
|
|
// All rows visible without filter.
|
|
rows, total, err := ListAgentAuditEvents(ListAgentAuditEventsFilter{})
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if total != 5 || len(rows) != 5 {
|
|
t.Errorf("expected 5 rows, got total=%d len=%d", total, len(rows))
|
|
}
|
|
|
|
// user_id filter narrows to 3 (i=0,2,4 → user-B).
|
|
_, total, err = ListAgentAuditEvents(ListAgentAuditEventsFilter{UserID: "user-B"})
|
|
if err != nil {
|
|
t.Fatalf("list with userid: %v", err)
|
|
}
|
|
if total != 3 {
|
|
t.Errorf("user-B should have 3 rows, got %d", total)
|
|
}
|
|
|
|
// Newest-first ordering. evt_4 inserted last → top.
|
|
rows, _, _ = ListAgentAuditEvents(ListAgentAuditEventsFilter{})
|
|
if rows[0].EventID != "evt_4" {
|
|
t.Errorf("expected newest-first, got %q on top", rows[0].EventID)
|
|
}
|
|
}
|
|
|
|
func TestListAgentAuditEventsByDeployment_Chronological(t *testing.T) {
|
|
// Detail-drawer reads need oldest-first for a top-to-bottom
|
|
// timeline. Confirms ascending order independent of insertion
|
|
// order.
|
|
setupAuditTest(t)
|
|
|
|
InsertAgentAuditEvent(&AgentAuditEvent{
|
|
EventID: "evt_late", Event: "x", DeploymentID: "dep_T",
|
|
OccurredAt: 9000,
|
|
})
|
|
InsertAgentAuditEvent(&AgentAuditEvent{
|
|
EventID: "evt_early", Event: "x", DeploymentID: "dep_T",
|
|
OccurredAt: 1000,
|
|
})
|
|
|
|
rows, err := ListAgentAuditEventsByDeployment("dep_T")
|
|
if err != nil {
|
|
t.Fatalf("query: %v", err)
|
|
}
|
|
if len(rows) != 2 || rows[0].EventID != "evt_early" {
|
|
t.Fatalf("expected [evt_early, evt_late] order, got %+v", rows)
|
|
}
|
|
}
|
|
|
|
// TestRedactAuditSecrets locks the audit-log redaction security boundary:
|
|
// plaintext secrets must never reach the audit table's free-form DetailsJSON.
|
|
// Covers every shape secretRedactors targets (sk-, Bearer, URL query token,
|
|
// JSON password/api_key/private_key) and guards against over-redacting normal
|
|
// text. This is a security boundary, so it ships as a persistent unit test
|
|
// rather than a one-off smoke check (see #9).
|
|
func TestRedactAuditSecrets(t *testing.T) {
|
|
const redacted = "***REDACTED***"
|
|
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
mustNotHave string // a secret value that must be gone after redaction
|
|
mustHave []string // substrings that must survive (keys kept, normal text intact)
|
|
}{
|
|
{
|
|
name: "sk- gateway key",
|
|
in: `{"failure_reason":"upstream rejected key sk-abcDEF1234567890"}`,
|
|
mustNotHave: "sk-abcDEF1234567890",
|
|
mustHave: []string{"sk-" + redacted, `"failure_reason"`},
|
|
},
|
|
{
|
|
name: "Authorization Bearer token",
|
|
in: `called callback with header Authorization: Bearer aZ09._-tokenValue123`,
|
|
mustNotHave: "aZ09._-tokenValue123",
|
|
mustHave: []string{redacted, "Authorization"},
|
|
},
|
|
{
|
|
name: "URL query token keeps key drops value",
|
|
in: `callback_url=https://hook.example.com/cb?token=supersecretval123&phase=deploy`,
|
|
mustNotHave: "supersecretval123",
|
|
mustHave: []string{"token=" + redacted, "phase=deploy"}, // non-secret param survives
|
|
},
|
|
{
|
|
name: "URL query access_key",
|
|
in: `https://x/y?access_key=AKIA1234567890abcd®ion=eastus`,
|
|
mustNotHave: "AKIA1234567890abcd",
|
|
mustHave: []string{"access_key=" + redacted, "region=eastus"},
|
|
},
|
|
{
|
|
name: "JSON password field",
|
|
in: `{"db":"pg","password":"hunter2pass","host":"db.local"}`,
|
|
mustNotHave: "hunter2pass",
|
|
mustHave: []string{`"password":"` + redacted + `"`, `"host":"db.local"`},
|
|
},
|
|
{
|
|
name: "JSON api_key value",
|
|
in: `{"api_key":"k-LIVE-9988","note":"ok"}`,
|
|
mustNotHave: "k-LIVE-9988",
|
|
mustHave: []string{redacted, `"note":"ok"`},
|
|
},
|
|
{
|
|
name: "JSON private_key value",
|
|
in: `{"private_key":"MIIEvQIBADANBgkq"}`,
|
|
mustNotHave: "MIIEvQIBADANBgkq",
|
|
mustHave: []string{`"private_key":"` + redacted + `"`},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := redactAuditSecrets(tc.in)
|
|
if tc.mustNotHave != "" && strings.Contains(got, tc.mustNotHave) {
|
|
t.Fatalf("secret leaked: %q still present in %q", tc.mustNotHave, got)
|
|
}
|
|
for _, want := range tc.mustHave {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("expected %q to survive, got %q", want, got)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// Guard: normal audit text with no secret shape must pass through unchanged —
|
|
// over-redaction would corrupt legitimate audit context.
|
|
for _, s := range []string{
|
|
"deploy failed: AM returned HTTP 500, pod not ready",
|
|
"phase=provisioning binding_scope=git-master status=running",
|
|
"agent dep_abc123 stopped by user",
|
|
} {
|
|
if got := redactAuditSecrets(s); got != s {
|
|
t.Fatalf("normal text over-redacted: %q -> %q", s, got)
|
|
}
|
|
}
|
|
|
|
// Empty input is returned unchanged.
|
|
if got := redactAuditSecrets(""); got != "" {
|
|
t.Fatalf("empty input changed to %q", got)
|
|
}
|
|
}
|