Files
tiny-forge/internal/workload/plugin/events_test.go
alexei.dolgolyov 93b6911b34 feat(apps): per-app deploy/activity timeline
Every deploy across all four source kinds now writes a workload-scoped
event via a shared plugin.EmitDeployEvent helper (replacing the inline
emit duplicated in static/dockerfile, standardizing static's metadata
key site_id->workload_id, and adding emission to image+compose which
were silent). New indexed event_log.workload_id column, EventLogFilter
.WorkloadID, and GET /api/workloads/{id}/events (id pinned from path).

Frontend: a forge "Activity" panel on /apps/[id] reusing EventLogEntry,
live SSE prepend filtered by workload_id, load-more pagination, an
All/Errors severity filter, and a shared toEventLogEntry mapper. en/ru
i18n parity.

Security: compose's failure status emits a generic reason instead of raw
`docker compose up` output, which can echo app secrets and egresses to
operator webhooks (NotificationURL + event-trigger actions); full detail
stays only in the returned error. Rune-safe 256-rune status cap.

Reviewed: go + typescript APPROVE; security HIGH fixed.
2026-05-29 13:51:17 +03:00

168 lines
5.4 KiB
Go

package plugin
import (
"encoding/json"
"strings"
"testing"
"unicode/utf8"
"github.com/alexei/tinyforge/internal/events"
"github.com/alexei/tinyforge/internal/store"
)
// capturePublisher records every event published on it so a test can
// assert on the bus payload. Satisfies plugin.EventPublisher.
type capturePublisher struct {
events []events.Event
}
func (c *capturePublisher) Publish(evt events.Event) {
c.events = append(c.events, evt)
}
// newEmitDeps builds a plugin.Deps backed by an in-memory store and a
// capturing publisher. Mirrors the in-memory store pattern used by the
// store + source-plugin tests.
func newEmitDeps(t *testing.T) (Deps, *capturePublisher) {
t.Helper()
st, err := store.New(":memory:")
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
pub := &capturePublisher{}
return Deps{Store: st, Events: pub}, pub
}
func TestEmitDeployEvent(t *testing.T) {
tests := []struct {
name string
status string
wantSeverity string
}{
{name: "deployed is info", status: "deployed", wantSeverity: "info"},
{name: "deploying is info", status: "deploying", wantSeverity: "info"},
{name: "failed is error", status: "failed: pull foo failed", wantSeverity: "error"},
{name: "failed bare is error", status: "failed", wantSeverity: "error"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
deps, pub := newEmitDeps(t)
w := Workload{ID: "wl-123", Name: "my-app"}
EmitDeployEvent(deps, w, "image", tt.status)
// Persisted row carries the workload scope + derived severity.
rows, err := deps.Store.ListEvents(store.EventLogFilter{WorkloadID: w.ID})
if err != nil {
t.Fatalf("ListEvents: %v", err)
}
if len(rows) != 1 {
t.Fatalf("got %d persisted events, want 1", len(rows))
}
got := rows[0]
if got.Severity != tt.wantSeverity {
t.Errorf("severity = %q, want %q", got.Severity, tt.wantSeverity)
}
if got.Source != "image" {
t.Errorf("source = %q, want %q", got.Source, "image")
}
if got.WorkloadID != w.ID {
t.Errorf("workload_id = %q, want %q", got.WorkloadID, w.ID)
}
wantMsg := w.Name + ": " + tt.status
if got.Message != wantMsg {
t.Errorf("message = %q, want %q", got.Message, wantMsg)
}
// Metadata JSON carries workload_id / workload_name / status.
var meta map[string]string
if err := json.Unmarshal([]byte(got.Metadata), &meta); err != nil {
t.Fatalf("unmarshal metadata %q: %v", got.Metadata, err)
}
if meta["workload_id"] != w.ID {
t.Errorf("metadata workload_id = %q, want %q", meta["workload_id"], w.ID)
}
if meta["workload_name"] != w.Name {
t.Errorf("metadata workload_name = %q, want %q", meta["workload_name"], w.Name)
}
if meta["status"] != tt.status {
t.Errorf("metadata status = %q, want %q", meta["status"], tt.status)
}
// The persisted row is also re-published on the bus as an
// EventLog so SSE clients see it live.
if len(pub.events) != 1 {
t.Fatalf("got %d published events, want 1", len(pub.events))
}
ev := pub.events[0]
if ev.Type != events.EventLog {
t.Errorf("event type = %q, want %q", ev.Type, events.EventLog)
}
payload, ok := ev.Payload.(events.EventLogPayload)
if !ok {
t.Fatalf("payload type = %T, want events.EventLogPayload", ev.Payload)
}
if payload.WorkloadID != w.ID {
t.Errorf("payload workload_id = %q, want %q", payload.WorkloadID, w.ID)
}
if payload.Severity != tt.wantSeverity {
t.Errorf("payload severity = %q, want %q", payload.Severity, tt.wantSeverity)
}
if payload.ID != got.ID {
t.Errorf("payload id = %d, want %d", payload.ID, got.ID)
}
})
}
}
// TestEmitDeployEvent_CapsLongStatus verifies a long failure status (e.g. one
// embedding raw subprocess output) is bounded to maxDeployStatusRunes runes in
// both the persisted message and metadata, cut on a UTF-8 boundary, while
// severity is still derived from the original "failed" prefix.
func TestEmitDeployEvent_CapsLongStatus(t *testing.T) {
deps, pub := newEmitDeps(t)
w := Workload{ID: "wl-cap", Name: "app"}
// Multibyte body so a naive byte-slice would corrupt a rune; prefix with
// "failed: " so the severity check exercises the pre-cap derivation.
longStatus := "failed: " + strings.Repeat("é", 400)
EmitDeployEvent(deps, w, "compose", longStatus)
rows, err := deps.Store.ListEvents(store.EventLogFilter{WorkloadID: w.ID})
if err != nil {
t.Fatalf("ListEvents: %v", err)
}
if len(rows) != 1 {
t.Fatalf("got %d events, want 1", len(rows))
}
got := rows[0]
if got.Severity != "error" {
t.Errorf("severity = %q, want error (derived from pre-cap prefix)", got.Severity)
}
var meta map[string]string
if err := json.Unmarshal([]byte(got.Metadata), &meta); err != nil {
t.Fatalf("unmarshal metadata: %v", err)
}
capped := meta["status"]
if rc := len([]rune(capped)); rc != maxDeployStatusRunes+1 { // +1 for the ellipsis rune
t.Errorf("capped status = %d runes, want %d", rc, maxDeployStatusRunes+1)
}
if !utf8.ValidString(capped) {
t.Errorf("capped status is not valid UTF-8: %q", capped)
}
if !strings.HasSuffix(capped, "…") {
t.Errorf("capped status missing ellipsis suffix: %q", capped)
}
wantMsg := w.Name + ": " + capped
if got.Message != wantMsg {
t.Errorf("message = %q, want %q", got.Message, wantMsg)
}
if len(pub.events) != 1 {
t.Fatalf("got %d published events, want 1", len(pub.events))
}
}