Feature: Offline device history with delete and remote uninstall
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
This commit is contained in:
1 parent
1922f87dd2
commit
b1ddec1b8a
12 files changed
+1212
-27
No files matched your search
@@ -0,0 +1,373 @@
|
||||
// Package devicehistory persists the set of devices that have ever logged in
|
||||
// to the Go server, so the web UI can keep listing them after they go offline.
|
||||
//
|
||||
// It implements hub.EventHandler and is subscribed to the hub, which means the
|
||||
// only wiring needed at startup is hub.Subscribe(store). Online events upsert
|
||||
// the record (refreshing the display fields the client reports at login) and
|
||||
// offline events merely refresh last_seen.
|
||||
//
|
||||
// Storage is a small JSON file, mirroring the wsauth users.json convention:
|
||||
// atomic temp-file + fsync + rename, loaded once at startup and written back
|
||||
// on a short debounce so a burst of reconnects doesn't rewrite it per device.
|
||||
package devicehistory
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// fileVersion lets a future release migrate the on-disk shape. Unknown
|
||||
// versions are still parsed best-effort; only structurally invalid JSON
|
||||
// makes Open fail.
|
||||
fileVersion = 1
|
||||
|
||||
// flushDelay coalesces updates. Device logins/disconnects are sparse, but
|
||||
// a server restart makes every agent reconnect at once; without the
|
||||
// debounce that would be one file write per device.
|
||||
flushDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// Record is one persisted device. The fields mirror hub.DeviceInfo so the
|
||||
// offline list can be rendered with the same front-end card code; only the
|
||||
// fields that are stable across sessions are kept (RTT / active window are
|
||||
// live-only and would be stale noise on an offline card).
|
||||
type Record struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Capability string `json:"capability,omitempty"`
|
||||
OS string `json:"os,omitempty"`
|
||||
CPU string `json:"cpu,omitempty"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
InstallTime string `json:"install_time,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
PeerIP string `json:"peer_ip,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
Screen string `json:"screen,omitempty"`
|
||||
|
||||
FirstSeen int64 `json:"first_seen"`
|
||||
LastSeen int64 `json:"last_seen"`
|
||||
}
|
||||
|
||||
type fileShape struct {
|
||||
Version int `json:"version"`
|
||||
Devices map[string]*Record `json:"devices"`
|
||||
}
|
||||
|
||||
// Store is the in-memory device history plus its backing file. Safe for
|
||||
// concurrent use.
|
||||
type Store struct {
|
||||
path string
|
||||
max int // 0 = unlimited
|
||||
log *logger.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
devices map[string]*Record
|
||||
// online is derived from hub events and used only by pruning: a device
|
||||
// that is still connected must keep its record even if its last login is
|
||||
// older than the 1000th offline device's. Not persisted — after a restart
|
||||
// every device is offline until it reconnects.
|
||||
online map[string]bool
|
||||
dirty bool
|
||||
timer *time.Timer
|
||||
}
|
||||
|
||||
// Open loads the history at path. A missing file is an empty history, not an
|
||||
// error. If the file exists but cannot be parsed, the returned store is still
|
||||
// usable (empty) along with the error, so callers can log and continue — the
|
||||
// next flush will replace the corrupt file.
|
||||
func Open(path string, max int, log *logger.Logger) (*Store, error) {
|
||||
s := newStore(path, max, log)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return s, nil
|
||||
}
|
||||
return s, err
|
||||
}
|
||||
var f fileShape
|
||||
if err := json.Unmarshal(data, &f); err != nil {
|
||||
return s, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
for id, r := range f.Devices {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
// The map key is authoritative; tolerate records written by a
|
||||
// future/older shape that omitted or disagreed with the id field.
|
||||
r.ID = id
|
||||
s.devices[id] = r
|
||||
}
|
||||
s.pruneLocked()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// New returns an empty store for path without touching disk. Useful when Open
|
||||
// failed and the caller still wants live recording to work.
|
||||
func New(path string, max int, log *logger.Logger) *Store {
|
||||
return newStore(path, max, log)
|
||||
}
|
||||
|
||||
func newStore(path string, max int, log *logger.Logger) *Store {
|
||||
return &Store{
|
||||
path: path,
|
||||
max: max,
|
||||
log: log,
|
||||
devices: make(map[string]*Record),
|
||||
online: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// OnDeviceOnline records a device that just logged in. It refreshes every
|
||||
// display field because the agent reports its current name/group/version at
|
||||
// each login, and preserves first_seen across reconnects.
|
||||
func (s *Store) OnDeviceOnline(d hub.DeviceInfo) {
|
||||
s.upsertAt(d, time.Now())
|
||||
}
|
||||
|
||||
func (s *Store) upsertAt(d hub.DeviceInfo, at time.Time) {
|
||||
if d.ID == "" {
|
||||
return
|
||||
}
|
||||
now := at.Unix()
|
||||
|
||||
s.mu.Lock()
|
||||
rec, ok := s.devices[d.ID]
|
||||
if !ok {
|
||||
rec = &Record{ID: d.ID, FirstSeen: now}
|
||||
s.devices[d.ID] = rec
|
||||
}
|
||||
if rec.FirstSeen == 0 {
|
||||
rec.FirstSeen = now
|
||||
}
|
||||
rec.Name = d.Name
|
||||
rec.Group = d.Group
|
||||
rec.Version = d.Version
|
||||
rec.Capability = d.Capability
|
||||
rec.OS = d.OS
|
||||
rec.CPU = d.CPU
|
||||
rec.FilePath = d.FilePath
|
||||
rec.InstallTime = d.InstallTime
|
||||
rec.Location = d.Location
|
||||
rec.PeerIP = d.PeerIP
|
||||
rec.IP = d.IP
|
||||
rec.Screen = d.Screen
|
||||
rec.LastSeen = now
|
||||
s.online[d.ID] = true
|
||||
|
||||
s.pruneLocked()
|
||||
s.markDirtyLocked()
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// OnDeviceOffline refreshes last_seen. The record was created when the device
|
||||
// logged in; an unknown ID is ignored (e.g. a device rejected by signing never
|
||||
// reaches the hub).
|
||||
func (s *Store) OnDeviceOffline(id string) {
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
s.offlineAt(id, time.Now())
|
||||
}
|
||||
|
||||
func (s *Store) offlineAt(id string, at time.Time) {
|
||||
s.mu.Lock()
|
||||
delete(s.online, id)
|
||||
if rec, ok := s.devices[id]; ok {
|
||||
rec.LastSeen = at.Unix()
|
||||
s.markDirtyLocked()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// The remaining EventHandler methods are not relevant to device history.
|
||||
func (s *Store) OnDeviceUpdate(string, int, string) {}
|
||||
func (s *Store) OnScreenFrame(string, []byte, bool) {}
|
||||
func (s *Store) OnResolutionChange(string, int, int) {}
|
||||
func (s *Store) OnCursorChange(string, byte) {}
|
||||
func (s *Store) OnClipboardText(string, string) {}
|
||||
func (s *Store) OnTerminalReady(string, bool) {}
|
||||
func (s *Store) OnTerminalData(string, []byte) {}
|
||||
func (s *Store) OnTerminalClosed(string, string) {}
|
||||
|
||||
// Offline returns the records whose IDs are not in online, newest last_seen
|
||||
// first, converted to the hub's device payload shape with Online=false.
|
||||
//
|
||||
// Callers pass the hub's current online snapshot; the store deliberately does
|
||||
// not know which device is online, so a reconnect that re-registers the same
|
||||
// ID can never produce a duplicate row.
|
||||
func (s *Store) Offline(online map[string]struct{}) []hub.DeviceInfo {
|
||||
s.mu.Lock()
|
||||
out := make([]hub.DeviceInfo, 0, len(s.devices))
|
||||
for id, rec := range s.devices {
|
||||
if _, ok := online[id]; ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, hub.DeviceInfo{
|
||||
ID: rec.ID,
|
||||
Name: rec.Name,
|
||||
Group: rec.Group,
|
||||
Version: rec.Version,
|
||||
Capability: rec.Capability,
|
||||
OS: rec.OS,
|
||||
CPU: rec.CPU,
|
||||
FilePath: rec.FilePath,
|
||||
InstallTime: rec.InstallTime,
|
||||
Location: rec.Location,
|
||||
IP: rec.IP,
|
||||
PeerIP: rec.PeerIP,
|
||||
Screen: rec.Screen,
|
||||
LastSeen: rec.LastSeen,
|
||||
Online: false,
|
||||
})
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].LastSeen != out[j].LastSeen {
|
||||
return out[i].LastSeen > out[j].LastSeen
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Remove deletes the persisted record for id and reports whether one existed.
|
||||
// This is the web UI's explicit "delete" for an offline device: an offline
|
||||
// record has no live agent to uninstall, so forgetting it is all "delete" can
|
||||
// mean. The caller decides when removal is appropriate; the store does not
|
||||
// refuse to drop a record that happens to be marked online.
|
||||
func (s *Store) Remove(id string) bool {
|
||||
if id == "" {
|
||||
return false
|
||||
}
|
||||
s.mu.Lock()
|
||||
_, existed := s.devices[id]
|
||||
if existed {
|
||||
delete(s.devices, id)
|
||||
delete(s.online, id)
|
||||
s.markDirtyLocked()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return existed
|
||||
}
|
||||
|
||||
// Flush writes the current state synchronously. Normally the debounced timer
|
||||
// does this; Flush exists for tests and graceful shutdown.
|
||||
func (s *Store) Flush() error {
|
||||
s.mu.Lock()
|
||||
if s.timer != nil {
|
||||
s.timer.Stop()
|
||||
s.timer = nil
|
||||
}
|
||||
if !s.dirty {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.dirty = false
|
||||
data, err := s.marshalLocked()
|
||||
s.mu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomic(s.path, data); err != nil {
|
||||
s.mu.Lock()
|
||||
s.dirty = true
|
||||
s.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close stops the debounce timer and flushes pending changes.
|
||||
func (s *Store) Close() error {
|
||||
return s.Flush()
|
||||
}
|
||||
|
||||
func (s *Store) markDirtyLocked() {
|
||||
s.dirty = true
|
||||
if s.timer == nil {
|
||||
s.timer = time.AfterFunc(flushDelay, s.flush)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) flush() {
|
||||
if err := s.Flush(); err != nil && s.log != nil {
|
||||
s.log.Error("Failed to persist device history to %s: %v", s.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) marshalLocked() ([]byte, error) {
|
||||
f := fileShape{Version: fileVersion, Devices: s.devices}
|
||||
return json.MarshalIndent(f, "", " ")
|
||||
}
|
||||
|
||||
// pruneLocked keeps the max most-recently-seen records. It runs after every
|
||||
// online upsert, which is the only path that can grow the map; the O(n log n)
|
||||
// sort only happens when the cap is actually exceeded.
|
||||
func (s *Store) pruneLocked() {
|
||||
if s.max <= 0 || len(s.devices) <= s.max {
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(s.devices))
|
||||
for id := range s.devices {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool {
|
||||
oi, oj := s.online[ids[i]], s.online[ids[j]]
|
||||
if oi != oj {
|
||||
return !oi // evict offline records before online ones
|
||||
}
|
||||
return s.devices[ids[i]].LastSeen < s.devices[ids[j]].LastSeen
|
||||
})
|
||||
for _, id := range ids[:len(ids)-s.max] {
|
||||
delete(s.devices, id)
|
||||
}
|
||||
}
|
||||
|
||||
// writeAtomic mirrors wsauth.writeUsersFile: temp file in the same directory,
|
||||
// fsync, then rename over the destination.
|
||||
func writeAtomic(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, "devices-*.json.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package devicehistory
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||
)
|
||||
|
||||
func testDevice(id, name string) hub.DeviceInfo {
|
||||
return hub.DeviceInfo{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Group: "office",
|
||||
Version: "1.2.3",
|
||||
OS: "Windows 11",
|
||||
IP: "203.0.113.7",
|
||||
PeerIP: "198.51.100.9",
|
||||
Screen: "1:1920*1080",
|
||||
Online: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRecordsLifecycleViaHub(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "devices.json")
|
||||
s, err := Open(path, 1000, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
h := hub.New()
|
||||
h.Subscribe(s)
|
||||
|
||||
h.Register(&hub.Device{ID: "dev-1", Name: "PC-1", Group: "office"},
|
||||
&connection.Context{RemoteAddr: "198.51.100.9:5000"})
|
||||
|
||||
// The device is online, so it must not appear in the offline list.
|
||||
if got := s.Offline(map[string]struct{}{"dev-1": {}}); len(got) != 0 {
|
||||
t.Fatalf("offline while online = %d, want 0", len(got))
|
||||
}
|
||||
|
||||
h.Unregister("dev-1")
|
||||
|
||||
off := s.Offline(nil)
|
||||
if len(off) != 1 {
|
||||
t.Fatalf("offline entries = %d, want 1", len(off))
|
||||
}
|
||||
d := off[0]
|
||||
if d.ID != "dev-1" || d.Name != "PC-1" || d.Group != "office" {
|
||||
t.Fatalf("offline device = %+v", d)
|
||||
}
|
||||
if d.Online {
|
||||
t.Fatal("offline entry marked online")
|
||||
}
|
||||
if d.LastSeen == 0 {
|
||||
t.Fatal("last_seen not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorePersistsAndPreservesFirstSeen(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "devices.json")
|
||||
s, err := Open(path, 1000, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
|
||||
first := time.Unix(1_700_000_000, 0)
|
||||
s.upsertAt(testDevice("dev-1", "old-name"), first)
|
||||
// A later login refreshes display fields and last_seen but keeps first_seen.
|
||||
s.upsertAt(testDevice("dev-1", "new-name"), first.Add(time.Hour))
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
_ = s.Close()
|
||||
|
||||
reopened, err := Open(path, 1000, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = reopened.Close() })
|
||||
|
||||
reopened.mu.Lock()
|
||||
rec := reopened.devices["dev-1"]
|
||||
reopened.mu.Unlock()
|
||||
if rec == nil {
|
||||
t.Fatal("record not persisted")
|
||||
}
|
||||
if rec.Name != "new-name" {
|
||||
t.Fatalf("name = %q, want new-name", rec.Name)
|
||||
}
|
||||
if rec.FirstSeen != first.Unix() {
|
||||
t.Fatalf("first_seen = %d, want %d", rec.FirstSeen, first.Unix())
|
||||
}
|
||||
if rec.LastSeen != first.Add(time.Hour).Unix() {
|
||||
t.Fatalf("last_seen = %d, want %d", rec.LastSeen, first.Add(time.Hour).Unix())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorePrunesOldest(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "devices.json")
|
||||
s, err := Open(path, 2, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
base := time.Unix(1_700_000_000, 0)
|
||||
s.upsertAt(testDevice("a", "A"), base)
|
||||
s.upsertAt(testDevice("b", "B"), base.Add(time.Minute))
|
||||
s.upsertAt(testDevice("c", "C"), base.Add(2*time.Minute))
|
||||
|
||||
s.mu.Lock()
|
||||
_, hasA := s.devices["a"]
|
||||
_, hasC := s.devices["c"]
|
||||
n := len(s.devices)
|
||||
s.mu.Unlock()
|
||||
if n != 2 || hasA || !hasC {
|
||||
t.Fatalf("after prune: n=%d hasA=%v hasC=%v, want 2/false/true", n, hasA, hasC)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneEvictsOfflineBeforeOnline(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "devices.json")
|
||||
s, err := Open(path, 2, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
base := time.Unix(1_700_000_000, 0)
|
||||
s.upsertAt(testDevice("online-old", "Old"), base)
|
||||
s.upsertAt(testDevice("offline-new", "Gone"), base.Add(time.Minute))
|
||||
s.offlineAt("offline-new", base.Add(2*time.Minute))
|
||||
// This upsert exceeds the cap; the offline record must be evicted before
|
||||
// the still-connected device, even though its last_seen is newer.
|
||||
s.upsertAt(testDevice("online-2", "New"), base.Add(3*time.Minute))
|
||||
|
||||
s.mu.Lock()
|
||||
_, hasOld := s.devices["online-old"]
|
||||
_, hasGone := s.devices["offline-new"]
|
||||
_, hasNew := s.devices["online-2"]
|
||||
n := len(s.devices)
|
||||
s.mu.Unlock()
|
||||
if n != 2 || !hasOld || hasGone || !hasNew {
|
||||
t.Fatalf("after prune: n=%d hasOld=%v hasGone=%v hasNew=%v", n, hasOld, hasGone, hasNew)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreOfflineSortedNewestFirst(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "devices.json")
|
||||
s, err := Open(path, 1000, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
base := time.Unix(1_700_000_000, 0)
|
||||
s.upsertAt(testDevice("old", "Old"), base)
|
||||
s.upsertAt(testDevice("new", "New"), base.Add(time.Hour))
|
||||
|
||||
off := s.Offline(nil)
|
||||
if len(off) != 2 || off[0].ID != "new" || off[1].ID != "old" {
|
||||
t.Fatalf("offline order = %+v, want new then old", off)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCorruptFileStartsEmptyAndRecovers(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "devices.json")
|
||||
if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s, err := Open(path, 1000, nil)
|
||||
if err == nil {
|
||||
t.Fatal("Open corrupt file: want error, got nil")
|
||||
}
|
||||
if len(s.devices) != 0 {
|
||||
t.Fatalf("corrupt load produced %d records, want 0", len(s.devices))
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
s.upsertAt(testDevice("dev-1", "PC-1"), time.Unix(1_700_000_000, 0))
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatalf("Flush after corrupt load: %v", err)
|
||||
}
|
||||
|
||||
reopened, err := Open(path, 1000, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = reopened.Close() })
|
||||
if len(reopened.devices) != 1 {
|
||||
t.Fatalf("records after recovery = %d, want 1", len(reopened.devices))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRemovePersistsDeletion(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "devices.json")
|
||||
s, err := Open(path, 1000, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
|
||||
base := time.Unix(1_700_000_000, 0)
|
||||
s.upsertAt(testDevice("gone", "Gone"), base)
|
||||
s.upsertAt(testDevice("kept", "Kept"), base.Add(time.Minute))
|
||||
|
||||
if !s.Remove("gone") {
|
||||
t.Fatal("Remove existing record = false, want true")
|
||||
}
|
||||
if s.Remove("gone") {
|
||||
t.Fatal("Remove missing record = true, want false")
|
||||
}
|
||||
if got := s.Offline(nil); len(got) != 1 || got[0].ID != "kept" {
|
||||
t.Fatalf("offline after remove = %+v, want only kept", got)
|
||||
}
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
_ = s.Close()
|
||||
|
||||
reopened, err := Open(path, 1000, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = reopened.Close() })
|
||||
if _, ok := reopened.devices["gone"]; ok {
|
||||
t.Fatal("removed record came back after reload")
|
||||
}
|
||||
if _, ok := reopened.devices["kept"]; !ok {
|
||||
t.Fatal("kept record missing after reload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushLeavesNoTempFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "devices.json")
|
||||
s, err := Open(path, 1000, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
s.upsertAt(testDevice("dev-1", "PC-1"), time.Unix(1_700_000_000, 0))
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".tmp") {
|
||||
t.Fatalf("leftover temp file: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user