backupy-agent/apps/agent/internal/pipeline/hooks_test.go
TronoSfera 8b0c978337 feat(initial): Backupy agent + backupy-decrypt CLI
Source ports from the TronoSfera/backupy-cloud monorepo:
- apps/agent/        — Go agent (WSS client, persistent queue, Docker
                       discovery, 5 DB drivers: PG/MySQL/Mongo/Redis/SQLite,
                       pre/post hooks, Prometheus metrics)
- apps/backupy-decrypt/ — standalone CLI for client-side decryption
- packages/proto/    — protobuf wire format (generated .pb.go committed
                       so the repo builds without protoc)
- docs/              — agent spec + wire-protocol contract

Apache-2.0 license. Image published to ghcr.io/tronosfera/backupy-agent
on every v* tag via .github/workflows/release.yml (multi-arch amd64+arm64).
2026-05-17 20:22:35 +03:00

202 lines
5.4 KiB
Go

package pipeline
import (
"context"
"strings"
"testing"
"time"
)
func TestRunHook_Success(t *testing.T) {
res, err := RunHook(context.Background(), "echo hi", nil, time.Second)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.ExitCode != 0 {
t.Errorf("ExitCode=%d want 0", res.ExitCode)
}
if res.Stdout != "hi\n" {
t.Errorf("Stdout=%q want %q", res.Stdout, "hi\n")
}
if res.TimedOut {
t.Errorf("TimedOut=true; should be false on success")
}
if res.Duration <= 0 {
t.Errorf("Duration=%v should be > 0", res.Duration)
}
}
func TestRunHook_NonZeroExit(t *testing.T) {
res, err := RunHook(context.Background(), "false", nil, time.Second)
if err == nil {
t.Fatalf("expected error for non-zero exit")
}
if res.ExitCode != 1 {
t.Errorf("ExitCode=%d want 1", res.ExitCode)
}
if res.TimedOut {
t.Errorf("TimedOut should be false for plain non-zero exit")
}
}
func TestRunHook_Timeout(t *testing.T) {
start := time.Now()
res, err := RunHook(context.Background(), "sleep 10", nil, 100*time.Millisecond)
elapsed := time.Since(start)
if err == nil {
t.Fatalf("expected timeout error")
}
if !res.TimedOut {
t.Errorf("TimedOut=false; want true")
}
if res.ExitCode != -1 {
t.Errorf("ExitCode=%d want -1 on timeout", res.ExitCode)
}
// Killed within reasonable slack of the timeout.
if elapsed > 5*time.Second {
t.Errorf("hook took %v, should die well under 5s", elapsed)
}
}
func TestRunHook_ContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
var res HookResult
var err error
start := time.Now()
go func() {
res, err = RunHook(ctx, "sleep 30", nil, 30*time.Second)
close(done)
}()
// Let the process actually start before we cancel.
time.Sleep(50 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatalf("hook did not exit within 2s of cancel")
}
if err == nil {
t.Errorf("expected error on cancel")
}
if res.TimedOut {
t.Errorf("cancel should NOT be reported as TimedOut")
}
if res.ExitCode != -1 {
t.Errorf("ExitCode=%d want -1 on cancel", res.ExitCode)
}
// Sanity: we cancelled fast.
if time.Since(start) > 5*time.Second {
t.Errorf("cancel took too long: %v", time.Since(start))
}
}
func TestRunHook_StdoutTruncation(t *testing.T) {
// Produce ~100 KB to stdout; ring buffer keeps last 8 KB.
// `yes "X" | head -c 102400` is portable across BSD + GNU userland.
cmd := `yes X | head -c 102400`
res, err := RunHook(context.Background(), cmd, nil, 10*time.Second)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(res.Stdout) != HookOutputBufBytes {
t.Errorf("stdout length=%d want %d (ring buffer cap)", len(res.Stdout), HookOutputBufBytes)
}
// Every byte in the buffer should be a captured 'X' or '\n' (from yes).
for i, c := range res.Stdout {
if c != 'X' && c != '\n' {
t.Errorf("unexpected byte at offset %d: %q", i, c)
break
}
}
}
func TestRunHook_EnvPassedThrough(t *testing.T) {
res, err := RunHook(context.Background(), `echo "$BACKUPY_TEST_VAR"`, []string{"BACKUPY_TEST_VAR=hello"}, time.Second)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.TrimSpace(res.Stdout) != "hello" {
t.Errorf("stdout=%q want %q", res.Stdout, "hello")
}
}
func TestRunHook_EmptyCommandRejected(t *testing.T) {
if _, err := RunHook(context.Background(), "", nil, time.Second); err == nil {
t.Error("empty command must be rejected")
}
}
func TestRunHook_StderrCaptured(t *testing.T) {
res, err := RunHook(context.Background(), `echo "oops" 1>&2; exit 1`, nil, time.Second)
if err == nil {
t.Fatalf("expected non-zero exit error")
}
if res.ExitCode != 1 {
t.Errorf("ExitCode=%d want 1", res.ExitCode)
}
if strings.TrimSpace(res.Stderr) != "oops" {
t.Errorf("stderr=%q want %q", res.Stderr, "oops")
}
}
func TestHookSet_BudgetExhaustion(t *testing.T) {
hs := NewHookSet()
// Pre-charge past the budget so the next Run is immediately denied.
hs.consumed = HooksTotalBudget + time.Second
_, err := hs.Run(context.Background(), "true", nil, time.Second)
if err == nil {
t.Error("expected ErrHooksBudgetExceeded after budget consumed")
}
}
func TestHookSet_NormalRunCharges(t *testing.T) {
hs := NewHookSet()
res, err := hs.Run(context.Background(), "true", nil, time.Second)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.ExitCode != 0 {
t.Errorf("ExitCode=%d want 0", res.ExitCode)
}
if hs.consumed <= 0 {
t.Errorf("consumed=%v should be > 0 after a run", hs.consumed)
}
}
func TestHookRingBuffer_Truncation(t *testing.T) {
buf := newHookRingBuffer(8)
// Write more than capacity in chunks.
for _, chunk := range []string{"abcd", "efgh", "ijkl"} {
if _, err := buf.Write([]byte(chunk)); err != nil {
t.Fatalf("Write: %v", err)
}
}
got := buf.String()
if got != "efghijkl" {
t.Errorf("ring buffer kept %q, want %q", got, "efghijkl")
}
}
func TestHookRingBuffer_SingleHugeWrite(t *testing.T) {
buf := newHookRingBuffer(8)
// Single write much larger than capacity.
src := []byte("0123456789ABCDEF")
if _, err := buf.Write(src); err != nil {
t.Fatalf("Write: %v", err)
}
got := buf.String()
if got != "89ABCDEF" {
t.Errorf("ring buffer kept %q, want %q", got, "89ABCDEF")
}
}
func TestHookRingBuffer_SmallWriteUnderCap(t *testing.T) {
buf := newHookRingBuffer(16)
if _, err := buf.Write([]byte("hello")); err != nil {
t.Fatal(err)
}
if got := buf.String(); got != "hello" {
t.Errorf("got %q want %q", got, "hello")
}
}