Persist every device that has ever logged in to the Go server in a small JSON store (devices.json, overridable with YAMA_DEVICES_FILE and capped by YAMA_DEVICES_MAX, default 1000 newest). The store follows the users.json convention: atomic temp-file write with fsync and rename, a 500 ms debounce for bursts of reconnects, first_seen preserved across logins, and pruning that evicts offline records before devices that are still connected. Devices connected at shutdown are stamped offline before the final flush so last_seen stays accurate across restarts. The server advertises an offlineDevices capability and device_list now appends historical devices that are not online, marked with online:false and last_seen. The web UI shows an Offline checkbox after Total/Showing (state kept in localStorage), renders offline cards dimmed and dashed, and always lists them in the screen-wall sidebar. Older servers without the capability keep the online-only list. Grid and List cards gain a delete button after the terminal button, admin only, behind a confirmation dialog. Deleting an offline device removes its history record; deleting an online device first sends the single-byte TOKEN_UNINSTALL command (matching the MFC OnOnlineUninstall flow) and then removes the record too, so the device does not come back as a dead offline entry. The agent's own disconnect then runs the existing cleanup path: screen and terminal sub-connections are closed, the hub entry is removed, and browsers receive device_offline to release their watch sets, wall sessions, and stream references. Wall mode exposes no delete action. Tests cover history persistence/pruning/recovery, offline ordering, the device_list merge, and all three delete outcomes (offline removal, online uninstall with a single 0x3F byte, and viewer rejection). Co-Authored-By: deepseek-flash
132 lines
3.7 KiB
Go
132 lines
3.7 KiB
Go
package web
|
|
|
|
import (
|
|
"encoding/json"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
|
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
|
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
|
)
|
|
|
|
// deleteTestHub builds the minimal wsHub wiring handleDeleteDevice needs.
|
|
func deleteTestHub(t *testing.T) (*wsHub, *hub.Hub, *devicehistory.Store) {
|
|
t.Helper()
|
|
h := hub.New()
|
|
hist, err := devicehistory.Open(filepath.Join(t.TempDir(), "devices.json"), 100, nil)
|
|
if err != nil {
|
|
t.Fatalf("Open history: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = hist.Close() })
|
|
h.Subscribe(hist)
|
|
|
|
auth := wsauth.New()
|
|
wh := &wsHub{
|
|
auth: auth,
|
|
devices: h,
|
|
history: hist,
|
|
log: logger.New(logger.Config{}),
|
|
clients: make(map[*wsClient]struct{}),
|
|
}
|
|
return wh, h, hist
|
|
}
|
|
|
|
func deleteTestClient(wh *wsHub, role string) (*wsClient, string) {
|
|
tok, err := wh.auth.IssueToken("tester", role)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
c := &wsClient{send: make(chan wsMsg, 8), closed: make(chan struct{})}
|
|
wh.register(c)
|
|
return c, tok
|
|
}
|
|
|
|
func deleteReply(t *testing.T, c *wsClient) map[string]any {
|
|
t.Helper()
|
|
for {
|
|
select {
|
|
case m := <-c.send:
|
|
var out map[string]any
|
|
if err := json.Unmarshal(m.data, &out); err != nil {
|
|
t.Fatalf("decode reply: %v", err)
|
|
}
|
|
if out["cmd"] == "delete_device_result" {
|
|
return out
|
|
}
|
|
default:
|
|
t.Fatal("no delete_device_result reply")
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDeleteDeviceOfflineRemovesHistoryRecord(t *testing.T) {
|
|
wh, h, hist := deleteTestHub(t)
|
|
h.Register(&hub.Device{ID: "gone", Name: "Gone"},
|
|
&connection.Context{RemoteAddr: "10.0.0.1:1000"})
|
|
h.Unregister("gone")
|
|
|
|
c, tok := deleteTestClient(wh, "admin")
|
|
raw, _ := json.Marshal(map[string]any{"token": tok, "id": "gone"})
|
|
wh.handleDeleteDevice(c, raw)
|
|
|
|
rep := deleteReply(t, c)
|
|
if rep["ok"] != true || rep["mode"] != "removed" {
|
|
t.Fatalf("reply = %+v, want ok/removed", rep)
|
|
}
|
|
if hist.Remove("gone") {
|
|
t.Fatal("history record still present after delete")
|
|
}
|
|
}
|
|
|
|
func TestDeleteDeviceOnlineSendsUninstall(t *testing.T) {
|
|
wh, h, hist := deleteTestHub(t)
|
|
var sent []byte
|
|
h.SetSender(func(_ *connection.Context, data []byte) error {
|
|
sent = append([]byte(nil), data...)
|
|
return nil
|
|
})
|
|
h.Register(&hub.Device{ID: "live", Name: "Live"},
|
|
&connection.Context{RemoteAddr: "10.0.0.2:1000"})
|
|
|
|
c, tok := deleteTestClient(wh, "admin")
|
|
raw, _ := json.Marshal(map[string]any{"token": tok, "id": "live"})
|
|
wh.handleDeleteDevice(c, raw)
|
|
|
|
rep := deleteReply(t, c)
|
|
if rep["ok"] != true || rep["mode"] != "uninstall" {
|
|
t.Fatalf("reply = %+v, want ok/uninstall", rep)
|
|
}
|
|
if len(sent) != 1 || sent[0] != protocol.CommandUninstall {
|
|
t.Fatalf("sent = %v, want single %d byte", sent, protocol.CommandUninstall)
|
|
}
|
|
// The history record is dropped along with the uninstall, so the device
|
|
// does not reappear as a dead offline entry after it disconnects.
|
|
if hist.Remove("live") {
|
|
t.Fatal("online device's history record survived the uninstall")
|
|
}
|
|
}
|
|
|
|
func TestDeleteDeviceRequiresAdmin(t *testing.T) {
|
|
wh, h, hist := deleteTestHub(t)
|
|
h.Register(&hub.Device{ID: "gone", Name: "Gone"},
|
|
&connection.Context{RemoteAddr: "10.0.0.3:1000"})
|
|
h.Unregister("gone")
|
|
|
|
c, tok := deleteTestClient(wh, "viewer")
|
|
raw, _ := json.Marshal(map[string]any{"token": tok, "id": "gone"})
|
|
wh.handleDeleteDevice(c, raw)
|
|
|
|
rep := deleteReply(t, c)
|
|
if rep["ok"] != false {
|
|
t.Fatalf("reply = %+v, want ok:false", rep)
|
|
}
|
|
if !hist.Remove("gone") {
|
|
t.Fatal("viewer delete removed the history record")
|
|
}
|
|
}
|