backupy-agent/apps/agent/internal/wss/client_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

178 lines
5.3 KiB
Go

package wss
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/coder/websocket"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
agentproto "github.com/backupy/backupy/apps/agent/internal/proto"
"github.com/backupy/backupy/apps/agent/internal/queue"
)
func TestBuildWSURL(t *testing.T) {
tests := []struct {
name string
in string
insecure bool
want string
expectErr bool
}{
{"https rewrites to wss", "https://api.example.com", false, "wss://api.example.com/v1/agents/connect", false},
{"http rejected without flag", "http://localhost:8080", false, "", true},
{"http accepted with flag", "http://localhost:8080", true, "ws://localhost:8080/v1/agents/connect", false},
{"already wss preserved", "wss://api.example.com", false, "wss://api.example.com/v1/agents/connect", false},
{"path preserved", "https://api.example.com/proxy", false, "wss://api.example.com/proxy/v1/agents/connect", false},
{"unknown scheme", "ftp://nope", false, "", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := buildWSURL(tc.in, tc.insecure)
if tc.expectErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tc.want, got)
})
}
}
func TestClient_DispatchPingReturnsAck(t *testing.T) {
c := &Client{}
env := agentproto.NewEnvelope()
env.CorrelationId = "ping-1"
env.Payload = &agentproto.Envelope_Ping{Ping: &agentproto.Ping{TsMs: 123}}
// Send needs an out channel or queue; here we exercise the Send
// fallback path: with queue=nil and out=nil, Send returns an error
// that we intentionally swallow. Dispatch should not panic.
require.NotPanics(t, func() {
_ = c.dispatch(context.Background(), env)
})
}
// fakeServer is a minimal coder/websocket echo that performs a
// Register/RegisterAck handshake then records any inbound frames into a
// channel. Used to drive end-to-end client behaviour without spinning
// up the real server package.
func fakeServer(t *testing.T, inboundCh chan<- *agentproto.Envelope) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "Bearer test-key", r.Header.Get("Authorization"))
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
require.NoError(t, err)
ctx := r.Context()
// Read Register.
_, data, err := ws.Read(ctx)
require.NoError(t, err)
env := &agentproto.Envelope{}
require.NoError(t, proto.Unmarshal(data, env))
_, ok := env.Payload.(*agentproto.Envelope_Register)
require.True(t, ok)
// Send RegisterAck.
ack := agentproto.NewEnvelope()
ack.Payload = &agentproto.Envelope_RegisterAck{RegisterAck: &agentproto.RegisterAck{
SessionId: "sess-1", HeartbeatIntervalSec: 30,
Config: &agentproto.AgentConfig{Version: 5},
}}
raw, _ := proto.Marshal(ack)
require.NoError(t, ws.Write(ctx, websocket.MessageBinary, raw))
// Forward subsequent frames to inboundCh until the conn closes.
for {
_, data, err := ws.Read(ctx)
if err != nil {
return
}
env := &agentproto.Envelope{}
if proto.Unmarshal(data, env) != nil {
continue
}
select {
case inboundCh <- env:
default:
}
}
}))
}
func TestClient_RegisterAndHeartbeat(t *testing.T) {
inbound := make(chan *agentproto.Envelope, 16)
ts := fakeServer(t, inbound)
defer ts.Close()
var hbSeen atomic.Bool
c := NewClient(Config{
ServerURL: ts.URL,
AgentKey: "test-key",
AgentVersion: "test",
AllowInsecure: true,
}, nil, nil, nil, func() *agentproto.AgentMetrics {
return &agentproto.AgentMetrics{CpuPercent: 1.5}
}, nil)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
go func() { _ = c.Start(ctx) }()
// Expect to see a Heartbeat in the inbound stream.
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
select {
case env := <-inbound:
if _, ok := env.Payload.(*agentproto.Envelope_Heartbeat); ok {
hbSeen.Store(true)
}
case <-time.After(100 * time.Millisecond):
}
if hbSeen.Load() {
break
}
}
require.True(t, hbSeen.Load(), "expected at least one Heartbeat envelope")
require.NotEmpty(t, c.SessionID())
require.Equal(t, uint64(5), c.ConfigVersion())
}
func TestClient_SendWithoutConnectionQueues(t *testing.T) {
// Use a memory queue stub.
q := &memQueue{}
c := NewClient(Config{
ServerURL: "wss://example.com",
AgentKey: "k",
AgentVersion: "v",
}, nil, q, nil, nil, nil)
env := agentproto.NewEnvelope()
env.CorrelationId = "x-1"
env.Payload = &agentproto.Envelope_Heartbeat{Heartbeat: &agentproto.Heartbeat{}}
require.NoError(t, c.Send(env))
require.Equal(t, 1, q.depth)
}
// memQueue is a stub queue used by the test above. It satisfies the
// queue.Queue interface without bringing in BoltDB.
type memQueue struct {
depth int
last []byte
}
func (m *memQueue) Enqueue(_ string, payload []byte) error {
m.depth++
m.last = payload
return nil
}
func (m *memQueue) Pop(_ context.Context, _ int) ([]queue.Job, error) { return nil, nil }
func (m *memQueue) Ack(_ string) error { return nil }
func (m *memQueue) Depth() (int, error) { return m.depth, nil }
// ensure strings is used to silence unused-import linter when tests
// shift around.
var _ = strings.TrimSpace