backupy-agent/apps/agent/internal/state/state_test.go
TronoSfera cc96a0b773 fix: include apps/agent/internal/state package (gitignore was eating it)
The .gitignore rule "state/" was unanchored, so git also ignored
apps/agent/internal/state/ — the BoltDB-backed queue persistence
package. CI build failed with:
  internal/queue/queue.go:13:2: no required module provides package
  github.com/backupy/backupy/apps/agent/internal/state

Anchored the rule to repo root (/state/, /var/) so it only matches
the runtime data directory, never a Go package.
2026-05-17 20:26:04 +03:00

168 lines
4.7 KiB
Go

package state
import (
"bytes"
"errors"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
const testAgentKey = "bkpy_test_abcdefghijklmnopqrstuvwxyz012345"
func newStore(t *testing.T) (*Store, string) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "state.db")
s, err := Open(path, Options{AgentKey: testAgentKey})
require.NoError(t, err)
t.Cleanup(func() { _ = s.Close() })
return s, path
}
func TestOpen_Empty(t *testing.T) {
s, _ := newStore(t)
_, _, err := s.LoadConfig()
require.ErrorIs(t, err, ErrNotFound)
_, err = s.LoadSession()
require.ErrorIs(t, err, ErrNotFound)
n, err := s.QueueDepth()
require.NoError(t, err)
require.Equal(t, 0, n)
}
func TestConfig_Roundtrip(t *testing.T) {
s, _ := newStore(t)
payload := []byte("hello protobuf bytes")
require.NoError(t, s.SaveConfig(42, payload))
v, got, err := s.LoadConfig()
require.NoError(t, err)
require.Equal(t, uint64(42), v)
require.Equal(t, payload, got)
}
func TestQueue_EnqueueDequeueAck(t *testing.T) {
s, _ := newStore(t)
require.NoError(t, s.EnqueueJob("run-a", []byte("aaa")))
require.NoError(t, s.EnqueueJob("run-b", []byte("bbb")))
require.NoError(t, s.EnqueueJob("run-c", []byte("ccc")))
n, err := s.QueueDepth()
require.NoError(t, err)
require.Equal(t, 3, n)
jobs, err := s.DequeueJobs(2)
require.NoError(t, err)
require.Len(t, jobs, 2)
require.Equal(t, "run-a", jobs[0].RunID)
require.Equal(t, []byte("aaa"), jobs[0].Payload)
require.Equal(t, "run-b", jobs[1].RunID)
// Ack pops the head; depth drops.
require.NoError(t, s.AckJob("run-a"))
n, _ = s.QueueDepth()
require.Equal(t, 2, n)
// Idempotent re-enqueue overwrites payload.
require.NoError(t, s.EnqueueJob("run-b", []byte("BBB")))
jobs, _ = s.DequeueJobs(10)
require.Equal(t, []byte("BBB"), jobs[0].Payload)
}
func TestSession(t *testing.T) {
s, _ := newStore(t)
require.NoError(t, s.SaveSession("sess-123", 1700000000000))
got, err := s.LoadSession()
require.NoError(t, err)
require.Equal(t, "sess-123", got)
}
func TestHeartbeat(t *testing.T) {
s, _ := newStore(t)
ts, err := s.LastHeartbeat()
require.NoError(t, err)
require.Equal(t, int64(0), ts)
require.NoError(t, s.RecordHeartbeat(1700000000000))
ts, err = s.LastHeartbeat()
require.NoError(t, err)
require.Equal(t, int64(1700000000000), ts)
}
func TestLogs_BufferAndDrain(t *testing.T) {
s, _ := newStore(t)
require.NoError(t, s.BufferLog(100, []byte("first")))
require.NoError(t, s.BufferLog(200, []byte("second")))
require.NoError(t, s.BufferLog(150, []byte("middle")))
drained, err := s.DrainLogs(10)
require.NoError(t, err)
require.Len(t, drained, 3)
// Chronological order — ts_ms wins, so: 100, 150, 200.
require.Equal(t, []byte("first"), drained[0])
require.Equal(t, []byte("middle"), drained[1])
require.Equal(t, []byte("second"), drained[2])
// Second drain returns nothing.
drained, err = s.DrainLogs(10)
require.NoError(t, err)
require.Empty(t, drained)
}
// TestEncryption_AtRest ensures values written to disk are NOT plaintext.
// This is the headline guarantee of the state package and the basis for
// task D-17 ("local state encryption").
func TestEncryption_AtRest(t *testing.T) {
s, path := newStore(t)
plaintext := []byte("super-secret-config-payload")
require.NoError(t, s.SaveConfig(1, plaintext))
require.NoError(t, s.EnqueueJob("run-x", []byte("queued-secret-x")))
// Close to flush before reading raw.
require.NoError(t, s.Close())
raw, err := os.ReadFile(path)
require.NoError(t, err)
require.False(t, bytes.Contains(raw, plaintext), "plaintext config leaked to disk")
require.False(t, bytes.Contains(raw, []byte("queued-secret-x")), "plaintext queue payload leaked to disk")
}
// TestEncryption_WrongKey ensures a different agent key cannot decrypt
// existing state — corruption / key rotation surfaces as a hard error
// rather than silently overwriting good data.
func TestEncryption_WrongKey(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "state.db")
s, err := Open(path, Options{AgentKey: testAgentKey})
require.NoError(t, err)
require.NoError(t, s.SaveConfig(7, []byte("payload")))
require.NoError(t, s.Close())
// Reopen with a different key — must fail to decrypt.
wrong := "bkpy_test_ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ"
s2, err := Open(path, Options{AgentKey: wrong})
require.NoError(t, err)
defer s2.Close()
_, _, err = s2.LoadConfig()
require.Error(t, err)
require.True(t, errors.Is(err, errCipher) || err.Error() != "", "expected cipher error, got: %v", err)
}
func TestOpen_EmptyKey(t *testing.T) {
dir := t.TempDir()
_, err := Open(filepath.Join(dir, "state.db"), Options{AgentKey: ""})
require.Error(t, err)
}
func TestEnqueue_EmptyRunID(t *testing.T) {
s, _ := newStore(t)
require.Error(t, s.EnqueueJob("", []byte("x")))
}