Files
UNITRONIX 3979e1fc08 fix(device): handle deleted ID reuse conflicts (#213)
Report reserved soft-deleted device IDs as explicit conflicts and expose a permanent delete path so operators can intentionally release IDs for reuse.
2026-06-18 18:17:35 +02:00

587 lines
15 KiB
Go

package api
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"time"
"github.com/unitronix/betterdesk-server/config"
"github.com/unitronix/betterdesk-server/db"
"github.com/unitronix/betterdesk-server/peer"
"github.com/unitronix/betterdesk-server/ratelimit"
"github.com/unitronix/betterdesk-server/security"
)
const testAPIKey = "test-api-key-12345"
// testAuthReq adds the X-API-Key header for authenticated test requests.
func testAuthReq(req *http.Request) *http.Request {
req.Header.Set("X-API-Key", testAPIKey)
return req
}
func testAuthGet(url string) (*http.Response, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
return http.DefaultClient.Do(testAuthReq(req))
}
// testSetupDB creates a temp database with migration and API key configured.
func testSetupDB(t *testing.T) db.Database {
t.Helper()
dir := t.TempDir()
database, err := db.OpenSQLite(dir + "/test.db")
if err != nil {
t.Fatal(err)
}
database.Migrate()
hash := sha256.Sum256([]byte(testAPIKey))
if err := database.CreateAPIKey(&db.APIKey{
KeyHash: hex.EncodeToString(hash[:]),
KeyPrefix: testAPIKey[:8],
Name: "test-key",
Role: "admin",
}); err != nil {
t.Fatal(err)
}
return database
}
func TestHealthEndpoint(t *testing.T) {
cfg := config.DefaultConfig()
database := testSetupDB(t)
defer database.Close()
// Add some test peers
database.UpsertPeer(&db.Peer{ID: "T1", Status: "ONLINE"})
database.UpsertPeer(&db.Peer{ID: "T2", Status: "OFFLINE"})
peerMap := peer.NewMap()
cfg.APIPort = 19876
srv := New(cfg, database, peerMap, nil, "1.0.0-test")
if err := srv.Start(t.Context()); err != nil {
t.Fatal(err)
}
defer srv.Stop()
time.Sleep(100 * time.Millisecond)
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/api/health", cfg.APIPort))
if err != nil {
t.Fatalf("GET /api/health: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("status: %d", resp.StatusCode)
}
var body map[string]any
json.NewDecoder(resp.Body).Decode(&body)
if body["status"] != "ok" {
t.Errorf("status field: %v", body["status"])
}
if body["version"] != "1.0.0-test" {
t.Errorf("version: %v", body["version"])
}
if body["peers_total"].(float64) != 2 {
t.Errorf("peers_total: %v", body["peers_total"])
}
}
func TestServerStatsEndpoint(t *testing.T) {
cfg := config.DefaultConfig()
database := testSetupDB(t)
defer database.Close()
peerMap := peer.NewMap()
peerMap.Put(&peer.Entry{ID: "MEM1", LastReg: time.Now()})
cfg.APIPort = 19877
srv := New(cfg, database, peerMap, nil, "1.0.0-test")
if err := srv.Start(t.Context()); err != nil {
t.Fatal(err)
}
defer srv.Stop()
time.Sleep(100 * time.Millisecond)
resp, err := testAuthGet(fmt.Sprintf("http://127.0.0.1:%d/api/server/stats", cfg.APIPort))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var body map[string]any
json.NewDecoder(resp.Body).Decode(&body)
if body["peers_in_memory"].(float64) != 1 {
t.Errorf("peers_in_memory: %v", body["peers_in_memory"])
}
if body["go_version"] == nil {
t.Error("go_version should be present")
}
}
func TestListPeersEndpoint(t *testing.T) {
cfg := config.DefaultConfig()
database := testSetupDB(t)
defer database.Close()
database.UpsertPeer(&db.Peer{ID: "P1", Hostname: "pc-1", Status: "ONLINE"})
database.UpsertPeer(&db.Peer{ID: "P2", Hostname: "pc-2", Status: "OFFLINE"})
peerMap := peer.NewMap()
cfg.APIPort = 19878
srv := New(cfg, database, peerMap, nil, "test")
srv.Start(t.Context())
defer srv.Stop()
time.Sleep(100 * time.Millisecond)
resp, err := testAuthGet(fmt.Sprintf("http://127.0.0.1:%d/api/peers", cfg.APIPort))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var peers []map[string]any
json.NewDecoder(resp.Body).Decode(&peers)
if len(peers) != 2 {
t.Errorf("expected 2 peers, got %d", len(peers))
}
}
func TestListPeersPaginatedEndpoint(t *testing.T) {
cfg := config.DefaultConfig()
database := testSetupDB(t)
defer database.Close()
for i := 1; i <= 3; i++ {
id := fmt.Sprintf("PG%02d", i)
database.UpsertPeer(&db.Peer{ID: id, Hostname: id, Status: "ONLINE"})
}
peerMap := peer.NewMap()
cfg.APIPort = 19880
srv := New(cfg, database, peerMap, nil, "test")
srv.Start(t.Context())
defer srv.Stop()
time.Sleep(100 * time.Millisecond)
resp, err := testAuthGet(fmt.Sprintf("http://127.0.0.1:%d/api/peers?limit=2&page=1", cfg.APIPort))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if int(body["total"].(float64)) != 3 {
t.Fatalf("total = %v, want 3", body["total"])
}
data, ok := body["data"].([]any)
if !ok || len(data) != 2 {
t.Fatalf("data len = %d, want 2", len(data))
}
}
func TestBanUnbanEndpoint(t *testing.T) {
cfg := config.DefaultConfig()
database := testSetupDB(t)
defer database.Close()
database.UpsertPeer(&db.Peer{ID: "BAN1", Status: "ONLINE"})
peerMap := peer.NewMap()
cfg.APIPort = 19879
srv := New(cfg, database, peerMap, nil, "test")
srv.Start(t.Context())
defer srv.Stop()
time.Sleep(100 * time.Millisecond)
// Ban
req, _ := http.NewRequest("POST", fmt.Sprintf("http://127.0.0.1:%d/api/peers/BAN1/ban", cfg.APIPort), nil)
testAuthReq(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("ban status: %d", resp.StatusCode)
}
banned, _ := database.IsPeerBanned("BAN1")
if !banned {
t.Error("peer should be banned")
}
// Unban
req, _ = http.NewRequest("POST", fmt.Sprintf("http://127.0.0.1:%d/api/peers/BAN1/unban", cfg.APIPort), nil)
testAuthReq(req)
resp, _ = http.DefaultClient.Do(req)
resp.Body.Close()
banned, _ = database.IsPeerBanned("BAN1")
if banned {
t.Error("peer should be unbanned")
}
}
// TestBanRemovesPeerFromMap verifies that banning a peer removes it from the
// in-memory peer map so it can no longer receive heartbeats or be targeted
// for PunchHole/Relay operations.
func TestBanRemovesPeerFromMap(t *testing.T) {
cfg := config.DefaultConfig()
database := testSetupDB(t)
defer database.Close()
database.UpsertPeer(&db.Peer{ID: "EVICT1", Status: "ONLINE"})
peerMap := peer.NewMap()
peerMap.Put(&peer.Entry{
ID: "EVICT1",
LastReg: time.Now(),
FirstSeen: time.Now(),
ConnType: peer.ConnUDP,
})
// Verify peer is in the map before ban
if peerMap.Get("EVICT1") == nil {
t.Fatal("peer should exist in map before ban")
}
cfg.APIPort = 19885
srv := New(cfg, database, peerMap, nil, "test")
srv.Start(t.Context())
defer srv.Stop()
time.Sleep(100 * time.Millisecond)
// Ban the peer
req, _ := http.NewRequest("POST", fmt.Sprintf("http://127.0.0.1:%d/api/peers/EVICT1/ban", cfg.APIPort),
strings.NewReader(`{"reason":"test ban"}`))
req.Header.Set("Content-Type", "application/json")
testAuthReq(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("ban returned status %d", resp.StatusCode)
}
// Verify: peer should be removed from memory map
if peerMap.Get("EVICT1") != nil {
t.Error("peer should be removed from memory map after ban")
}
// Verify: peer should be banned in database
banned, _ := database.IsPeerBanned("EVICT1")
if !banned {
t.Error("peer should be banned in database")
}
// Verify: peer status should be OFFLINE in database
dbPeer, _ := database.GetPeer("EVICT1")
if dbPeer != nil && dbPeer.Status != "OFFLINE" {
t.Errorf("peer status should be OFFLINE, got %s", dbPeer.Status)
}
}
func TestChangePeerIDSoftDeletedTargetReturnsConflict(t *testing.T) {
cfg := config.DefaultConfig()
database := testSetupDB(t)
defer database.Close()
if err := database.UpsertPeer(&db.Peer{ID: "OLD213", Status: "ONLINE"}); err != nil {
t.Fatal(err)
}
if err := database.UpsertPeer(&db.Peer{ID: "MACPRO", Status: "OFFLINE"}); err != nil {
t.Fatal(err)
}
if err := database.DeletePeer("MACPRO"); err != nil {
t.Fatal(err)
}
peerMap := peer.NewMap()
cfg.APIPort = 19886
srv := New(cfg, database, peerMap, nil, "test")
srv.Start(t.Context())
defer srv.Stop()
time.Sleep(100 * time.Millisecond)
req, _ := http.NewRequest("POST", fmt.Sprintf("http://127.0.0.1:%d/api/peers/OLD213/change-id", cfg.APIPort),
strings.NewReader(`{"new_id":"MACPRO"}`))
req.Header.Set("Content-Type", "application/json")
testAuthReq(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusConflict)
}
var body map[string]string
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if !strings.Contains(body["error"], "deleted device") {
t.Fatalf("error = %q, want deleted-device conflict", body["error"])
}
}
func TestStatusSummaryEndpoint(t *testing.T) {
cfg := config.DefaultConfig()
database := testSetupDB(t)
defer database.Close()
peerMap := peer.NewMap()
peerMap.Put(&peer.Entry{ID: "ON1", LastReg: time.Now(), FirstSeen: time.Now(), ConnType: peer.ConnUDP, MissedBeats: 0})
peerMap.Put(&peer.Entry{ID: "DEG1", LastReg: time.Now(), FirstSeen: time.Now(), ConnType: peer.ConnTCP, MissedBeats: 3})
peerMap.Put(&peer.Entry{ID: "CRIT1", LastReg: time.Now(), FirstSeen: time.Now(), ConnType: peer.ConnWS, MissedBeats: 5})
cfg.APIPort = 19880
srv := New(cfg, database, peerMap, nil, "test")
srv.Start(t.Context())
defer srv.Stop()
time.Sleep(100 * time.Millisecond)
resp, err := testAuthGet(fmt.Sprintf("http://127.0.0.1:%d/api/peers/status/summary", cfg.APIPort))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var body map[string]any
json.NewDecoder(resp.Body).Decode(&body)
if body["total"].(float64) != 3 {
t.Errorf("total: %v", body["total"])
}
if body["online"].(float64) != 1 {
t.Errorf("online: %v", body["online"])
}
if body["degraded"].(float64) != 1 {
t.Errorf("degraded: %v", body["degraded"])
}
if body["critical"].(float64) != 1 {
t.Errorf("critical: %v", body["critical"])
}
}
func TestOnlinePeersEndpoint(t *testing.T) {
cfg := config.DefaultConfig()
database := testSetupDB(t)
defer database.Close()
peerMap := peer.NewMap()
peerMap.Put(&peer.Entry{
ID: "LIVE1", LastReg: time.Now(), FirstSeen: time.Now(),
ConnType: peer.ConnUDP, Version: "1.2.3",
})
peerMap.Put(&peer.Entry{
ID: "LIVE2", LastReg: time.Now(), FirstSeen: time.Now(),
ConnType: peer.ConnTCP, MissedBeats: 3,
})
cfg.APIPort = 19881
srv := New(cfg, database, peerMap, nil, "test")
srv.Start(t.Context())
defer srv.Stop()
time.Sleep(100 * time.Millisecond)
resp, err := testAuthGet(fmt.Sprintf("http://127.0.0.1:%d/api/peers/online", cfg.APIPort))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var body map[string]any
json.NewDecoder(resp.Body).Decode(&body)
if body["count"].(float64) != 2 {
t.Errorf("count: %v", body["count"])
}
}
func TestPeerStatusEndpoint(t *testing.T) {
cfg := config.DefaultConfig()
database := testSetupDB(t)
defer database.Close()
// Add peer to memory (live)
peerMap := peer.NewMap()
peerMap.Put(&peer.Entry{
ID: "LIVE1", LastReg: time.Now(), FirstSeen: time.Now(),
ConnType: peer.ConnUDP, Version: "1.2.3", HeartbeatCount: 10,
})
// Add peer to DB only (offline)
database.UpsertPeer(&db.Peer{ID: "DBONLY", Status: "OFFLINE", Hostname: "my-pc"})
cfg.APIPort = 19882
srv := New(cfg, database, peerMap, nil, "test")
srv.Start(t.Context())
defer srv.Stop()
time.Sleep(100 * time.Millisecond)
// Test live peer
resp, err := testAuthGet(fmt.Sprintf("http://127.0.0.1:%d/api/peers/LIVE1/status", cfg.APIPort))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var body map[string]any
json.NewDecoder(resp.Body).Decode(&body)
if body["in_memory"] != true {
t.Errorf("in_memory: %v", body["in_memory"])
}
snapshot := body["snapshot"].(map[string]any)
if snapshot["id"] != "LIVE1" {
t.Errorf("snapshot.id: %v", snapshot["id"])
}
if snapshot["status"] != "ONLINE" {
t.Errorf("snapshot.status: %v", snapshot["status"])
}
// Test offline peer (DB only)
resp2, err := testAuthGet(fmt.Sprintf("http://127.0.0.1:%d/api/peers/DBONLY/status", cfg.APIPort))
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
var body2 map[string]any
json.NewDecoder(resp2.Body).Decode(&body2)
if body2["in_memory"] != false {
t.Errorf("in_memory: %v", body2["in_memory"])
}
if body2["status"] != "OFFLINE" {
t.Errorf("status: %v", body2["status"])
}
// Test nonexistent peer
resp3, err := testAuthGet(fmt.Sprintf("http://127.0.0.1:%d/api/peers/NOPE/status", cfg.APIPort))
if err != nil {
t.Fatal(err)
}
defer resp3.Body.Close()
if resp3.StatusCode != 404 {
t.Errorf("nonexistent peer status: %d", resp3.StatusCode)
}
}
func TestBlocklistEndpoints(t *testing.T) {
cfg := config.DefaultConfig()
database := testSetupDB(t)
defer database.Close()
peerMap := peer.NewMap()
bl := security.NewBlocklist()
cfg.APIPort = 19883
srv := New(cfg, database, peerMap, nil, "test")
srv.SetBlocklist(bl)
srv.Start(t.Context())
defer srv.Stop()
time.Sleep(100 * time.Millisecond)
baseURL := fmt.Sprintf("http://127.0.0.1:%d", cfg.APIPort)
// List (initially empty)
resp, _ := testAuthGet(baseURL + "/api/blocklist")
var body map[string]any
json.NewDecoder(resp.Body).Decode(&body)
resp.Body.Close()
if body["count"].(float64) != 0 {
t.Errorf("initial count: %v", body["count"])
}
// Add IP
req, _ := http.NewRequest("POST", baseURL+"/api/blocklist",
strings.NewReader(`{"value":"10.0.0.1","type":"ip","reason":"test"}`))
req.Header.Set("Content-Type", "application/json")
testAuthReq(req)
resp, _ = http.DefaultClient.Do(req)
resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("add IP status: %d", resp.StatusCode)
}
// Verify added
if !bl.IsIPBlocked("10.0.0.1") {
t.Error("10.0.0.1 should be blocked")
}
// Remove
req, _ = http.NewRequest("DELETE", baseURL+"/api/blocklist/10.0.0.1", nil)
testAuthReq(req)
resp, _ = http.DefaultClient.Do(req)
resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("remove status: %d", resp.StatusCode)
}
if bl.IsIPBlocked("10.0.0.1") {
t.Error("10.0.0.1 should be unblocked after removal")
}
}
func TestServerStatsWithBandwidthAndBlocklist(t *testing.T) {
cfg := config.DefaultConfig()
database := testSetupDB(t)
defer database.Close()
peerMap := peer.NewMap()
peerMap.Put(&peer.Entry{ID: "X", LastReg: time.Now(), FirstSeen: time.Now()})
bl := security.NewBlocklist()
bl.BlockIP("1.2.3.4", "test")
bwLimiter := ratelimit.NewBandwidthLimiter(1024*1024, 64*1024)
cfg.APIPort = 19884
srv := New(cfg, database, peerMap, nil, "test")
srv.SetBlocklist(bl)
srv.SetBandwidthLimiter(bwLimiter)
srv.Start(t.Context())
defer srv.Stop()
time.Sleep(100 * time.Millisecond)
resp, _ := testAuthGet(fmt.Sprintf("http://127.0.0.1:%d/api/server/stats", cfg.APIPort))
var body map[string]any
json.NewDecoder(resp.Body).Decode(&body)
resp.Body.Close()
if body["blocklist_count"].(float64) != 1 {
t.Errorf("blocklist_count: %v", body["blocklist_count"])
}
if body["peers_online_live"].(float64) != 1 {
t.Errorf("peers_online_live: %v", body["peers_online_live"])
}
if _, ok := body["bandwidth_bytes_transferred"]; !ok {
t.Error("bandwidth_bytes_transferred should be present")
}
if _, ok := body["total_registrations"]; !ok {
t.Error("total_registrations should be present")
}
}