1294 lines
39 KiB
Go
1294 lines
39 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/containarr/server/internal/broker"
|
|
grpcgateway "github.com/containarr/server/internal/grpc"
|
|
agentv1 "github.com/containarr/server/internal/proto/agentv1"
|
|
"github.com/containarr/server/internal/store"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
func newTestHandler(t *testing.T) (*Handler, *store.Store, *grpcgateway.Registry, *broker.Broker) {
|
|
t.Helper()
|
|
s, err := store.New(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("store.New: %v", err)
|
|
}
|
|
t.Cleanup(func() { s.Close() })
|
|
|
|
reg := grpcgateway.NewRegistry()
|
|
b := broker.New()
|
|
h := NewHandler(s, reg, b)
|
|
return h, s, reg, b
|
|
}
|
|
|
|
func makeJWT(t *testing.T, subject string) string {
|
|
t.Helper()
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, &jwtClaims{
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
Subject: subject,
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
},
|
|
})
|
|
signed, err := token.SignedString(jwtSecret())
|
|
if err != nil {
|
|
t.Fatalf("makeJWT: %v", err)
|
|
}
|
|
return signed
|
|
}
|
|
|
|
func bearerHeader(token string) string {
|
|
return "Bearer " + token
|
|
}
|
|
|
|
func postJSON(t *testing.T, handler http.HandlerFunc, path string, body any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
b, _ := json.Marshal(body)
|
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
handler(w, req)
|
|
return w
|
|
}
|
|
|
|
func postJSONAuth(t *testing.T, handler http.HandlerFunc, path string, body any, token string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
b, _ := json.Marshal(body)
|
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", bearerHeader(token))
|
|
w := httptest.NewRecorder()
|
|
|
|
// Wrap handler with requireJWT so claims land in context.
|
|
requireJWT(handler).ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
// ── extractToken ──────────────────────────────────────────────────────────────
|
|
|
|
func TestExtractToken_BearerHeader(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Authorization", "Bearer mytoken")
|
|
if got := extractToken(req); got != "mytoken" {
|
|
t.Errorf("expected 'mytoken', got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractToken_QueryParam(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/?token=querytoken", nil)
|
|
if got := extractToken(req); got != "querytoken" {
|
|
t.Errorf("expected 'querytoken', got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractToken_Empty(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
if got := extractToken(req); got != "" {
|
|
t.Errorf("expected empty, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractToken_ShortAuthHeader(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Authorization", "Bear") // len < 7
|
|
if got := extractToken(req); got != "" {
|
|
t.Errorf("expected empty for short header, got %q", got)
|
|
}
|
|
}
|
|
|
|
// ── requireJWT middleware ─────────────────────────────────────────────────────
|
|
|
|
func TestRequireJWT_MissingToken(t *testing.T) {
|
|
called := false
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true })
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
w := httptest.NewRecorder()
|
|
requireJWT(next).ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected 401, got %d", w.Code)
|
|
}
|
|
if called {
|
|
t.Error("handler should not be called without token")
|
|
}
|
|
}
|
|
|
|
func TestRequireJWT_InvalidToken(t *testing.T) {
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Authorization", "Bearer not.a.real.token")
|
|
w := httptest.NewRecorder()
|
|
requireJWT(next).ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected 401, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRequireJWT_ValidToken(t *testing.T) {
|
|
token := makeJWT(t, "alice")
|
|
called := false
|
|
var gotSubject string
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
c, ok := claimsFromContext(r)
|
|
if !ok {
|
|
t.Error("claims not in context")
|
|
return
|
|
}
|
|
gotSubject = c.Subject
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Authorization", bearerHeader(token))
|
|
w := httptest.NewRecorder()
|
|
requireJWT(next).ServeHTTP(w, req)
|
|
|
|
if !called {
|
|
t.Error("handler was not called")
|
|
}
|
|
if gotSubject != "alice" {
|
|
t.Errorf("expected subject 'alice', got %q", gotSubject)
|
|
}
|
|
}
|
|
|
|
func TestRequireJWT_WrongSecret(t *testing.T) {
|
|
// Sign with a different secret
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, &jwtClaims{
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
Subject: "hacker",
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
|
},
|
|
})
|
|
signed, _ := token.SignedString([]byte("wrong-secret"))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Authorization", bearerHeader(signed))
|
|
w := httptest.NewRecorder()
|
|
requireJWT(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})).ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected 401, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// ── Login ─────────────────────────────────────────────────────────────────────
|
|
|
|
func TestLogin_Success(t *testing.T) {
|
|
h, s, _, _ := newTestHandler(t)
|
|
|
|
hash, _ := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.MinCost)
|
|
_ = s.UpsertUser("alice", string(hash))
|
|
|
|
w := postJSON(t, h.Login, "/api/v1/auth/login", map[string]string{
|
|
"username": "alice",
|
|
"password": "password",
|
|
})
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d — body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]string
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp["token"] == "" {
|
|
t.Error("expected non-empty token in response")
|
|
}
|
|
}
|
|
|
|
func TestLogin_WrongPassword(t *testing.T) {
|
|
h, s, _, _ := newTestHandler(t)
|
|
|
|
hash, _ := bcrypt.GenerateFromPassword([]byte("correct"), bcrypt.MinCost)
|
|
_ = s.UpsertUser("alice", string(hash))
|
|
|
|
w := postJSON(t, h.Login, "/api/v1/auth/login", map[string]string{
|
|
"username": "alice",
|
|
"password": "wrong",
|
|
})
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected 401, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestLogin_UnknownUser(t *testing.T) {
|
|
h, _, _, _ := newTestHandler(t)
|
|
|
|
w := postJSON(t, h.Login, "/api/v1/auth/login", map[string]string{
|
|
"username": "nobody",
|
|
"password": "pass",
|
|
})
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected 401, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestLogin_BadBody(t *testing.T) {
|
|
h, _, _, _ := newTestHandler(t)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader("not-json"))
|
|
w := httptest.NewRecorder()
|
|
h.Login(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestLogin_EmptyFields(t *testing.T) {
|
|
h, _, _, _ := newTestHandler(t)
|
|
|
|
w := postJSON(t, h.Login, "/api/v1/auth/login", map[string]string{
|
|
"username": "",
|
|
"password": "",
|
|
})
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// ── ChangePassword ─────────────────────────────────────────────────────────────
|
|
|
|
func TestChangePassword_Success(t *testing.T) {
|
|
h, s, _, _ := newTestHandler(t)
|
|
os.Setenv("JWT_SECRET", "test-secret-change-pw")
|
|
defer os.Unsetenv("JWT_SECRET")
|
|
|
|
hash, _ := bcrypt.GenerateFromPassword([]byte("oldpass"), bcrypt.MinCost)
|
|
_ = s.UpsertUser("alice", string(hash))
|
|
|
|
token := makeJWT(t, "alice")
|
|
w := postJSONAuth(t, h.ChangePassword, "/api/v1/auth/change-password", map[string]string{
|
|
"current_password": "oldpass",
|
|
"new_password": "newpass",
|
|
}, token)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204, got %d — body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify new hash is stored
|
|
newHash, _ := s.GetUserHash("alice")
|
|
if bcrypt.CompareHashAndPassword([]byte(newHash), []byte("newpass")) != nil {
|
|
t.Error("new password hash does not match")
|
|
}
|
|
}
|
|
|
|
func TestChangePassword_WrongCurrentPassword(t *testing.T) {
|
|
h, s, _, _ := newTestHandler(t)
|
|
|
|
hash, _ := bcrypt.GenerateFromPassword([]byte("correct"), bcrypt.MinCost)
|
|
_ = s.UpsertUser("alice", string(hash))
|
|
|
|
token := makeJWT(t, "alice")
|
|
w := postJSONAuth(t, h.ChangePassword, "/api/v1/auth/change-password", map[string]string{
|
|
"current_password": "wrong",
|
|
"new_password": "newpass",
|
|
}, token)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected 401, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// ── ListAgents ────────────────────────────────────────────────────────────────
|
|
|
|
func TestListAgents_Empty(t *testing.T) {
|
|
h, _, _, _ := newTestHandler(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
|
w := httptest.NewRecorder()
|
|
h.ListAgents(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
var agents []agentDTO
|
|
if err := json.NewDecoder(w.Body).Decode(&agents); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(agents) != 0 {
|
|
t.Errorf("expected empty list, got %d", len(agents))
|
|
}
|
|
}
|
|
|
|
func TestListAgents_PersistenceAndLive(t *testing.T) {
|
|
h, s, reg, _ := newTestHandler(t)
|
|
|
|
_ = s.CreateAgentToken("a1", "t1", "host1")
|
|
// Register a2 in the registry (simulating live agent)
|
|
reg.Register("a2", "host2", "alias2", "192.168.1.1", "arm64", "linux")
|
|
_ = s.CreateAgentToken("a2", "t2", "host2")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
|
w := httptest.NewRecorder()
|
|
h.ListAgents(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
var agents []agentDTO
|
|
json.NewDecoder(w.Body).Decode(&agents)
|
|
|
|
if len(agents) != 2 {
|
|
t.Fatalf("expected 2 agents, got %d", len(agents))
|
|
}
|
|
|
|
// Find a2 — it should be online
|
|
var a2 *agentDTO
|
|
for i := range agents {
|
|
if agents[i].ID == "a2" {
|
|
a2 = &agents[i]
|
|
}
|
|
}
|
|
if a2 == nil {
|
|
t.Fatal("a2 not found in list")
|
|
}
|
|
if !a2.Online {
|
|
t.Error("a2 should be online (registered in registry)")
|
|
}
|
|
}
|
|
|
|
// ── CreateAgentToken ──────────────────────────────────────────────────────────
|
|
|
|
func TestCreateAgentToken_Success(t *testing.T) {
|
|
h, _, _, _ := newTestHandler(t)
|
|
|
|
b, _ := json.Marshal(map[string]string{"hostname": "new-agent"})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/token", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
h.CreateAgentToken(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d — body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]string
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
|
|
if resp["agent_id"] == "" || resp["token"] == "" {
|
|
t.Errorf("missing agent_id or token in response: %v", resp)
|
|
}
|
|
}
|
|
|
|
func TestCreateAgentToken_MissingHostname(t *testing.T) {
|
|
h, _, _, _ := newTestHandler(t)
|
|
|
|
b, _ := json.Marshal(map[string]string{"hostname": ""})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/token", bytes.NewReader(b))
|
|
w := httptest.NewRecorder()
|
|
h.CreateAgentToken(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// ── UpdateAgent ───────────────────────────────────────────────────────────────
|
|
|
|
func TestUpdateAgent_Success(t *testing.T) {
|
|
h, s, reg, _ := newTestHandler(t)
|
|
|
|
_ = s.CreateAgentToken("a1", "t1", "host1")
|
|
reg.Register("a1", "host1", "old", "ip", "arch", "os")
|
|
|
|
body, _ := json.Marshal(map[string]string{"alias": "new-alias"})
|
|
|
|
router := chi.NewRouter()
|
|
router.Patch("/api/v1/agents/{agentID}", h.UpdateAgent)
|
|
|
|
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/agents/a1", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d — body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp agentDTO
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
if resp.Alias != "new-alias" {
|
|
t.Errorf("expected alias 'new-alias', got %q", resp.Alias)
|
|
}
|
|
|
|
// Confirm registry also updated
|
|
state, ok := reg.Get("a1")
|
|
if !ok {
|
|
t.Fatal("agent not in registry")
|
|
}
|
|
if state.Alias != "new-alias" {
|
|
t.Errorf("registry alias not updated, got %q", state.Alias)
|
|
}
|
|
}
|
|
|
|
// ── ListContainers ─────────────────────────────────────────────────────────────
|
|
|
|
func TestListContainers_Empty(t *testing.T) {
|
|
h, _, _, _ := newTestHandler(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/containers", nil)
|
|
w := httptest.NewRecorder()
|
|
h.ListContainers(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestListContainers_WithData(t *testing.T) {
|
|
h, _, reg, _ := newTestHandler(t)
|
|
|
|
reg.Register("a1", "host1", "alias1", "10.0.0.1", "amd64", "linux")
|
|
reg.UpdateContainers("a1", []*agentv1.ContainerInfo{
|
|
{Id: "c1", Name: "web"},
|
|
{Id: "c2", Name: "db"},
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/containers", nil)
|
|
w := httptest.NewRecorder()
|
|
h.ListContainers(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
var out []struct {
|
|
AgentID string `json:"agent_id"`
|
|
Container *agentv1.ContainerInfo `json:"container"`
|
|
}
|
|
json.NewDecoder(w.Body).Decode(&out)
|
|
|
|
if len(out) != 2 {
|
|
t.Errorf("expected 2 containers, got %d", len(out))
|
|
}
|
|
}
|
|
|
|
// ── DeleteAgent ───────────────────────────────────────────────────────────────
|
|
|
|
func TestDeleteAgent_Success(t *testing.T) {
|
|
h, s, reg, _ := newTestHandler(t)
|
|
|
|
_ = s.CreateAgentToken("a1", "t1", "host1")
|
|
reg.Register("a1", "host1", "", "ip", "arch", "os")
|
|
|
|
router := chi.NewRouter()
|
|
router.Delete("/api/v1/agents/{agentID}", h.DeleteAgent)
|
|
|
|
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/a1", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204, got %d — body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Agent must be gone from store
|
|
_, err := s.GetAgent("a1")
|
|
if err == nil {
|
|
t.Error("expected store error after deletion, got nil")
|
|
}
|
|
|
|
// Agent must be gone from registry
|
|
_, ok := reg.Get("a1")
|
|
if ok {
|
|
t.Error("expected agent to be deregistered from registry")
|
|
}
|
|
}
|
|
|
|
func TestDeleteAgent_NotInRegistry(t *testing.T) {
|
|
h, s, _, _ := newTestHandler(t)
|
|
|
|
_ = s.CreateAgentToken("a1", "t1", "host1")
|
|
// Agent not registered in registry (offline agent)
|
|
|
|
router := chi.NewRouter()
|
|
router.Delete("/api/v1/agents/{agentID}", h.DeleteAgent)
|
|
|
|
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/a1", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204 even when not in registry, got %d — body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
_, err := s.GetAgent("a1")
|
|
if err == nil {
|
|
t.Error("expected store error after deletion, got nil")
|
|
}
|
|
}
|
|
|
|
func TestDeleteAgent_NonExistent(t *testing.T) {
|
|
h, _, _, _ := newTestHandler(t)
|
|
|
|
router := chi.NewRouter()
|
|
router.Delete("/api/v1/agents/{agentID}", h.DeleteAgent)
|
|
|
|
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/ghost", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
// DELETE on a non-existent ID is still 204 (idempotent)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204 for non-existent agent, got %d — body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// ── ListImages ────────────────────────────────────────────────────────────────
|
|
|
|
func TestListImages_Empty(t *testing.T) {
|
|
h, _, _, _ := newTestHandler(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/images", nil)
|
|
w := httptest.NewRecorder()
|
|
h.ListImages(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
var out []map[string]any
|
|
json.NewDecoder(w.Body).Decode(&out)
|
|
if len(out) != 0 {
|
|
t.Errorf("expected empty list, got %d", len(out))
|
|
}
|
|
}
|
|
|
|
func TestListImages_WithData(t *testing.T) {
|
|
h, _, reg, _ := newTestHandler(t)
|
|
|
|
reg.Register("a1", "host1", "alias1", "10.0.0.1", "amd64", "linux")
|
|
reg.UpdateResources("a1",
|
|
nil,
|
|
[]*agentv1.ImageInfo{
|
|
{Id: "sha256:abc", Tags: []string{"nginx:latest"}, Size: 50000000, CreatedAt: 1700000000},
|
|
{Id: "sha256:def", Tags: []string{"redis:7"}, Size: 30000000, CreatedAt: 1700000001},
|
|
},
|
|
nil,
|
|
nil,
|
|
)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/images", nil)
|
|
w := httptest.NewRecorder()
|
|
h.ListImages(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
var out []struct {
|
|
AgentID string `json:"agent_id"`
|
|
Hostname string `json:"hostname"`
|
|
ID string `json:"id"`
|
|
Tags []string `json:"tags"`
|
|
Size int64 `json:"size"`
|
|
CreatedAt int64 `json:"created_at"`
|
|
}
|
|
json.NewDecoder(w.Body).Decode(&out)
|
|
|
|
if len(out) != 2 {
|
|
t.Fatalf("expected 2 images, got %d", len(out))
|
|
}
|
|
if out[0].AgentID != "a1" {
|
|
t.Errorf("expected agent_id 'a1', got %q", out[0].AgentID)
|
|
}
|
|
if out[0].ID != "sha256:abc" && out[1].ID != "sha256:abc" {
|
|
t.Error("expected sha256:abc in results")
|
|
}
|
|
}
|
|
|
|
// ── ListVolumes ───────────────────────────────────────────────────────────────
|
|
|
|
func TestListVolumes_Empty(t *testing.T) {
|
|
h, _, _, _ := newTestHandler(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/volumes", nil)
|
|
w := httptest.NewRecorder()
|
|
h.ListVolumes(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
var out []map[string]any
|
|
json.NewDecoder(w.Body).Decode(&out)
|
|
if len(out) != 0 {
|
|
t.Errorf("expected empty list, got %d", len(out))
|
|
}
|
|
}
|
|
|
|
func TestListVolumes_WithData(t *testing.T) {
|
|
h, _, reg, _ := newTestHandler(t)
|
|
|
|
reg.Register("a1", "host1", "alias1", "10.0.0.1", "amd64", "linux")
|
|
reg.UpdateResources("a1",
|
|
nil,
|
|
nil,
|
|
[]*agentv1.VolumeInfo{
|
|
{Name: "data", Driver: "local", Mountpoint: "/var/lib/docker/volumes/data/_data"},
|
|
{Name: "cache", Driver: "local", Mountpoint: "/var/lib/docker/volumes/cache/_data"},
|
|
},
|
|
nil,
|
|
)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/volumes", nil)
|
|
w := httptest.NewRecorder()
|
|
h.ListVolumes(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
var out []struct {
|
|
AgentID string `json:"agent_id"`
|
|
Name string `json:"name"`
|
|
Driver string `json:"driver"`
|
|
Mountpoint string `json:"mountpoint"`
|
|
}
|
|
json.NewDecoder(w.Body).Decode(&out)
|
|
|
|
if len(out) != 2 {
|
|
t.Fatalf("expected 2 volumes, got %d", len(out))
|
|
}
|
|
names := map[string]bool{out[0].Name: true, out[1].Name: true}
|
|
if !names["data"] || !names["cache"] {
|
|
t.Errorf("expected volumes 'data' and 'cache', got %v", names)
|
|
}
|
|
}
|
|
|
|
// ── ListNetworks ──────────────────────────────────────────────────────────────
|
|
|
|
func TestListNetworks_Empty(t *testing.T) {
|
|
h, _, _, _ := newTestHandler(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/networks", nil)
|
|
w := httptest.NewRecorder()
|
|
h.ListNetworks(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
var out []map[string]any
|
|
json.NewDecoder(w.Body).Decode(&out)
|
|
if len(out) != 0 {
|
|
t.Errorf("expected empty list, got %d", len(out))
|
|
}
|
|
}
|
|
|
|
func TestListNetworks_WithData(t *testing.T) {
|
|
h, _, reg, _ := newTestHandler(t)
|
|
|
|
reg.Register("a1", "host1", "alias1", "10.0.0.1", "amd64", "linux")
|
|
reg.UpdateResources("a1",
|
|
nil,
|
|
nil,
|
|
nil,
|
|
[]*agentv1.NetworkInfo{
|
|
{Id: "net1", Name: "bridge", Driver: "bridge", Scope: "local"},
|
|
{Id: "net2", Name: "host", Driver: "host", Scope: "local"},
|
|
},
|
|
)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/networks", nil)
|
|
w := httptest.NewRecorder()
|
|
h.ListNetworks(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
var out []struct {
|
|
AgentID string `json:"agent_id"`
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Driver string `json:"driver"`
|
|
Scope string `json:"scope"`
|
|
}
|
|
json.NewDecoder(w.Body).Decode(&out)
|
|
|
|
if len(out) != 2 {
|
|
t.Fatalf("expected 2 networks, got %d", len(out))
|
|
}
|
|
ids := map[string]bool{out[0].ID: true, out[1].ID: true}
|
|
if !ids["net1"] || !ids["net2"] {
|
|
t.Errorf("expected net1 and net2, got %v", ids)
|
|
}
|
|
}
|
|
|
|
// ── ContainerAction ───────────────────────────────────────────────────────────
|
|
|
|
func TestContainerAction_AgentNotConnected(t *testing.T) {
|
|
h, _, _, _ := newTestHandler(t)
|
|
|
|
body, _ := json.Marshal(map[string]string{"action": "start"})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/ghost/containers/c1/action", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
router := chi.NewRouter()
|
|
router.Post("/api/v1/agents/{agentID}/containers/{containerID}/action", h.ContainerAction)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("expected 503, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestContainerAction_InvalidAction(t *testing.T) {
|
|
h, _, reg, _ := newTestHandler(t)
|
|
reg.Register("a1", "h", "a", "ip", "arch", "os")
|
|
|
|
body, _ := json.Marshal(map[string]string{"action": "explode"})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a1/containers/c1/action", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
router := chi.NewRouter()
|
|
router.Post("/api/v1/agents/{agentID}/containers/{containerID}/action", h.ContainerAction)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestContainerAction_Success(t *testing.T) {
|
|
h, _, reg, _ := newTestHandler(t)
|
|
reg.Register("a1", "h", "a", "ip", "arch", "os")
|
|
|
|
body, _ := json.Marshal(map[string]string{"action": "stop"})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a1/containers/c1/action", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
router := chi.NewRouter()
|
|
router.Post("/api/v1/agents/{agentID}/containers/{containerID}/action", h.ContainerAction)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d — body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]string
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
if resp["command_id"] == "" {
|
|
t.Error("expected command_id in response")
|
|
}
|
|
}
|
|
|
|
// newCancelledRequest creates a request with an already-cancelled context.
|
|
func newCancelledRequest(method, target string, body *bytes.Reader) *http.Request {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
var req *http.Request
|
|
if body != nil {
|
|
req = httptest.NewRequest(method, target, body)
|
|
} else {
|
|
req = httptest.NewRequest(method, target, nil)
|
|
}
|
|
return req.WithContext(ctx)
|
|
}
|
|
|
|
// ── FsList ────────────────────────────────────────────────────────────────────
|
|
|
|
func TestFsList_AgentNotFound(t *testing.T) {
|
|
h, _, _, _ := newTestHandler(t)
|
|
|
|
router := chi.NewRouter()
|
|
router.Get("/api/v1/agents/{agentID}/fs/list", h.FsList)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/ghost/fs/list?path=/tmp", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestFsList_Timeout(t *testing.T) {
|
|
h, _, reg, _ := newTestHandler(t)
|
|
reg.Register("a1", "h", "a", "ip", "arch", "os")
|
|
|
|
router := chi.NewRouter()
|
|
router.Get("/api/v1/agents/{agentID}/fs/list", h.FsList)
|
|
|
|
// Use cancelled context to force immediate timeout on the agent wait.
|
|
req := newCancelledRequest(http.MethodGet, "/api/v1/agents/a1/fs/list?path=/tmp", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
// Either 504 (timeout) or 404 (send failed because channel was full/cancelled).
|
|
if w.Code != http.StatusGatewayTimeout && w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 504 or 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
|
|
func TestFsList_MissingPath(t *testing.T) {
|
|
h, _, reg, _ := newTestHandler(t)
|
|
reg.Register("a1", "h", "a", "ip", "arch", "os")
|
|
|
|
router := chi.NewRouter()
|
|
router.Get("/api/v1/agents/{agentID}/fs/list", h.FsList)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/a1/fs/list", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// ── FsRead ────────────────────────────────────────────────────────────────────
|
|
|
|
func TestFsRead_AgentNotFound(t *testing.T) {
|
|
h, _, _, _ := newTestHandler(t)
|
|
|
|
router := chi.NewRouter()
|
|
router.Get("/api/v1/agents/{agentID}/fs/read", h.FsRead)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/ghost/fs/read?path=/etc/hosts", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestFsRead_MissingPath(t *testing.T) {
|
|
h, _, reg, _ := newTestHandler(t)
|
|
reg.Register("a1", "h", "a", "ip", "arch", "os")
|
|
|
|
router := chi.NewRouter()
|
|
router.Get("/api/v1/agents/{agentID}/fs/read", h.FsRead)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/a1/fs/read", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// ── FsWrite ───────────────────────────────────────────────────────────────────
|
|
|
|
func TestFsWrite_AgentNotFound(t *testing.T) {
|
|
h, _, _, _ := newTestHandler(t)
|
|
|
|
router := chi.NewRouter()
|
|
router.Post("/api/v1/agents/{agentID}/fs/write", h.FsWrite)
|
|
|
|
body, _ := json.Marshal(map[string]string{"path": "/tmp/test.txt", "content": "hello"})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/ghost/fs/write", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestFsWrite_MissingPath(t *testing.T) {
|
|
h, _, reg, _ := newTestHandler(t)
|
|
reg.Register("a1", "h", "a", "ip", "arch", "os")
|
|
|
|
router := chi.NewRouter()
|
|
router.Post("/api/v1/agents/{agentID}/fs/write", h.FsWrite)
|
|
|
|
body, _ := json.Marshal(map[string]string{"content": "hello"})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a1/fs/write", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// ── FsMkdir ───────────────────────────────────────────────────────────────────
|
|
|
|
func TestFsMkdir_AgentNotFound(t *testing.T) {
|
|
h, _, _, _ := newTestHandler(t)
|
|
|
|
router := chi.NewRouter()
|
|
router.Post("/api/v1/agents/{agentID}/fs/mkdir", h.FsMkdir)
|
|
|
|
body, _ := json.Marshal(map[string]string{"path": "/opt/stacks/nouveau-dossier"})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/ghost/fs/mkdir", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestFsMkdir_InvalidBody(t *testing.T) {
|
|
h, _, reg, _ := newTestHandler(t)
|
|
reg.Register("a1", "h", "a", "ip", "arch", "os")
|
|
|
|
router := chi.NewRouter()
|
|
router.Post("/api/v1/agents/{agentID}/fs/mkdir", h.FsMkdir)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a1/fs/mkdir", bytes.NewReader([]byte("not-json")))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// ── ComposeAction ─────────────────────────────────────────────────────────────
|
|
|
|
func TestComposeAction_AgentNotFound(t *testing.T) {
|
|
h, _, _, _ := newTestHandler(t)
|
|
|
|
router := chi.NewRouter()
|
|
router.Post("/api/v1/agents/{agentID}/compose", h.ComposeAction)
|
|
|
|
body, _ := json.Marshal(map[string]string{"path": "/opt/stack", "action": "up"})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/ghost/compose", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestComposeAction_InvalidAction(t *testing.T) {
|
|
h, _, reg, _ := newTestHandler(t)
|
|
reg.Register("a1", "h", "a", "ip", "arch", "os")
|
|
|
|
router := chi.NewRouter()
|
|
router.Post("/api/v1/agents/{agentID}/compose", h.ComposeAction)
|
|
|
|
body, _ := json.Marshal(map[string]string{"path": "/opt/stack", "action": "restart"})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a1/compose", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestComposeAction_MissingFields(t *testing.T) {
|
|
h, _, _, _ := newTestHandler(t)
|
|
|
|
router := chi.NewRouter()
|
|
router.Post("/api/v1/agents/{agentID}/compose", h.ComposeAction)
|
|
|
|
body, _ := json.Marshal(map[string]string{"action": "up"})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/ghost/compose", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestComposeAction_Timeout(t *testing.T) {
|
|
h, _, reg, _ := newTestHandler(t)
|
|
reg.Register("a1", "h", "a", "ip", "arch", "os")
|
|
|
|
router := chi.NewRouter()
|
|
router.Post("/api/v1/agents/{agentID}/compose", h.ComposeAction)
|
|
|
|
bodyBytes, _ := json.Marshal(map[string]string{"path": "/opt/stack", "action": "up"})
|
|
req := newCancelledRequest(http.MethodPost, "/api/v1/agents/a1/compose", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusGatewayTimeout && w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 504 or 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// ── ListImages is_orphan ──────────────────────────────────────────────────────
|
|
|
|
func TestListImages_IsOrphan(t *testing.T) {
|
|
h, _, reg, _ := newTestHandler(t)
|
|
|
|
reg.Register("a1", "host1", "alias1", "10.0.0.1", "amd64", "linux")
|
|
reg.UpdateResources("a1",
|
|
nil,
|
|
[]*agentv1.ImageInfo{
|
|
{Id: "sha256:orphan", Tags: []string{}, Size: 10000, CreatedAt: 1700000000, IsOrphan: true},
|
|
{Id: "sha256:used", Tags: []string{"app:latest"}, Size: 20000, CreatedAt: 1700000001, IsOrphan: false},
|
|
},
|
|
nil,
|
|
nil,
|
|
)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/images", nil)
|
|
w := httptest.NewRecorder()
|
|
h.ListImages(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
var out []struct {
|
|
ID string `json:"id"`
|
|
IsOrphan bool `json:"is_orphan"`
|
|
}
|
|
json.NewDecoder(w.Body).Decode(&out)
|
|
|
|
if len(out) != 2 {
|
|
t.Fatalf("expected 2 images, got %d", len(out))
|
|
}
|
|
for _, item := range out {
|
|
if item.ID == "sha256:orphan" && !item.IsOrphan {
|
|
t.Error("expected sha256:orphan to have is_orphan=true")
|
|
}
|
|
if item.ID == "sha256:used" && item.IsOrphan {
|
|
t.Error("expected sha256:used to have is_orphan=false")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── ListVolumes is_orphan ─────────────────────────────────────────────────────
|
|
|
|
func TestListVolumes_IsOrphan(t *testing.T) {
|
|
h, _, reg, _ := newTestHandler(t)
|
|
|
|
reg.Register("a1", "host1", "alias1", "10.0.0.1", "amd64", "linux")
|
|
reg.UpdateResources("a1",
|
|
nil,
|
|
nil,
|
|
[]*agentv1.VolumeInfo{
|
|
{Name: "orphaned-vol", Driver: "local", Mountpoint: "/var/lib/docker/volumes/orphaned-vol/_data", IsOrphan: true},
|
|
{Name: "active-vol", Driver: "local", Mountpoint: "/var/lib/docker/volumes/active-vol/_data", IsOrphan: false},
|
|
},
|
|
nil,
|
|
)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/volumes", nil)
|
|
w := httptest.NewRecorder()
|
|
h.ListVolumes(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
var out []struct {
|
|
Name string `json:"name"`
|
|
IsOrphan bool `json:"is_orphan"`
|
|
}
|
|
json.NewDecoder(w.Body).Decode(&out)
|
|
|
|
if len(out) != 2 {
|
|
t.Fatalf("expected 2 volumes, got %d", len(out))
|
|
}
|
|
for _, item := range out {
|
|
if item.Name == "orphaned-vol" && !item.IsOrphan {
|
|
t.Error("expected orphaned-vol to have is_orphan=true")
|
|
}
|
|
if item.Name == "active-vol" && item.IsOrphan {
|
|
t.Error("expected active-vol to have is_orphan=false")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── ListNetworks is_orphan ────────────────────────────────────────────────────
|
|
|
|
func TestListNetworks_IsOrphan(t *testing.T) {
|
|
h, _, reg, _ := newTestHandler(t)
|
|
|
|
reg.Register("a1", "host1", "alias1", "10.0.0.1", "amd64", "linux")
|
|
reg.UpdateResources("a1",
|
|
nil,
|
|
nil,
|
|
nil,
|
|
[]*agentv1.NetworkInfo{
|
|
{Id: "net-orphan", Name: "stale-net", Driver: "bridge", Scope: "local", IsOrphan: true},
|
|
{Id: "net-used", Name: "active-net", Driver: "bridge", Scope: "local", IsOrphan: false},
|
|
},
|
|
)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/networks", nil)
|
|
w := httptest.NewRecorder()
|
|
h.ListNetworks(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
var out []struct {
|
|
ID string `json:"id"`
|
|
IsOrphan bool `json:"is_orphan"`
|
|
}
|
|
json.NewDecoder(w.Body).Decode(&out)
|
|
|
|
if len(out) != 2 {
|
|
t.Fatalf("expected 2 networks, got %d", len(out))
|
|
}
|
|
for _, item := range out {
|
|
if item.ID == "net-orphan" && !item.IsOrphan {
|
|
t.Error("expected net-orphan to have is_orphan=true")
|
|
}
|
|
if item.ID == "net-used" && item.IsOrphan {
|
|
t.Error("expected net-used to have is_orphan=false")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── DeleteImage ───────────────────────────────────────────────────────────────
|
|
|
|
func TestDeleteImage_AgentNotConnected(t *testing.T) {
|
|
h, _, _, _ := newTestHandler(t)
|
|
|
|
router := chi.NewRouter()
|
|
router.Delete("/api/v1/agents/{agentID}/images/{imageID}", h.DeleteImage)
|
|
|
|
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/ghost/images/sha256:abc", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("expected 503, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestDeleteImage_Success(t *testing.T) {
|
|
h, _, reg, _ := newTestHandler(t)
|
|
reg.Register("a1", "h", "a", "ip", "arch", "os")
|
|
|
|
router := chi.NewRouter()
|
|
router.Delete("/api/v1/agents/{agentID}/images/{imageID}", h.DeleteImage)
|
|
|
|
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/a1/images/sha256:abc?force=true", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d — body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]string
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
if resp["command_id"] == "" {
|
|
t.Error("expected command_id in response")
|
|
}
|
|
}
|
|
|
|
// ── DeleteVolume ──────────────────────────────────────────────────────────────
|
|
|
|
func TestDeleteVolume_AgentNotConnected(t *testing.T) {
|
|
h, _, _, _ := newTestHandler(t)
|
|
|
|
router := chi.NewRouter()
|
|
router.Delete("/api/v1/agents/{agentID}/volumes/{volumeName}", h.DeleteVolume)
|
|
|
|
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/ghost/volumes/my-vol", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("expected 503, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestDeleteVolume_Success(t *testing.T) {
|
|
h, _, reg, _ := newTestHandler(t)
|
|
reg.Register("a1", "h", "a", "ip", "arch", "os")
|
|
|
|
router := chi.NewRouter()
|
|
router.Delete("/api/v1/agents/{agentID}/volumes/{volumeName}", h.DeleteVolume)
|
|
|
|
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/a1/volumes/my-vol", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d — body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]string
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
if resp["command_id"] == "" {
|
|
t.Error("expected command_id in response")
|
|
}
|
|
}
|
|
|
|
// ── DeleteNetwork ─────────────────────────────────────────────────────────────
|
|
|
|
func TestDeleteNetwork_AgentNotConnected(t *testing.T) {
|
|
h, _, _, _ := newTestHandler(t)
|
|
|
|
router := chi.NewRouter()
|
|
router.Delete("/api/v1/agents/{agentID}/networks/{networkID}", h.DeleteNetwork)
|
|
|
|
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/ghost/networks/net1", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("expected 503, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestDeleteNetwork_Success(t *testing.T) {
|
|
h, _, reg, _ := newTestHandler(t)
|
|
reg.Register("a1", "h", "a", "ip", "arch", "os")
|
|
|
|
router := chi.NewRouter()
|
|
router.Delete("/api/v1/agents/{agentID}/networks/{networkID}", h.DeleteNetwork)
|
|
|
|
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/a1/networks/net1", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d — body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]string
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
if resp["command_id"] == "" {
|
|
t.Error("expected command_id in response")
|
|
}
|
|
}
|