Compare commits

...

5 Commits

Author SHA1 Message Date
mirivlad 50e7e95844 test(sync): add end-to-end two-client sync smoke test
TestE2ESync starts a real server process, registers user, pairs two devices,
pushes a node op from client A, pulls on client B, verifies payload content,
and confirms /api/auth/test does not create devices.
2026-06-02 08:02:19 +08:00
mirivlad 4a96aa3468 fix(sync): expand payloads, implement ApplyRemoteOp, fix SyncTestConnection and auto sync
- Expand all RecordOp payloads with full entity data needed for reconstruction
- Add applyRemoteOp with handlers for node/note/file/action/worklog CRUD
- SyncNow() now applies pulled ops to real DB tables + sets LastSeenServerSeq
- SyncTestConnection uses /api/auth/test instead of PairDevice
- autoSyncLoop respects configured interval with lastSync timing
- Add helper functions: nodePayload, notePayload, filePayload, actionPayload, worklogPayload
2026-06-02 08:02:15 +08:00
mirivlad f8f9510e2a fix(sync): add /api/auth/test endpoint, fix CSS %& vet warnings
- Add handleAuthTest endpoint that validates credentials without creating a device
- Fix resetPasswordHTML to use {TOKEN} placeholder instead of %s
- Remove fmt.Sprintf from admin dashboard (no format args needed)
2026-06-02 08:02:11 +08:00
mirivlad 852d6d373c fix(sync): send LastSeenServerSeq from CLI push, report conflicts 2026-06-02 08:02:07 +08:00
mirivlad 3c7e9d1d56 fix(sync): add ClientSequence and LastSeenServerSeq to Op struct and Push
- Add ClientSequence and LastSeenServerSeq fields to sync.Op struct
- Fix Client.Push() to copy these fields to PushOp
- Add GetDeviceID() method to sync.Service
2026-06-02 08:02:03 +08:00
7 changed files with 1075 additions and 45 deletions

1
.gitignore vendored
View File

@ -25,6 +25,7 @@ frontend/bindings/
/verstak-gui
/verstak-cli
/verstak-server
/verstak
# Vault data
.verstak/

View File

@ -2,6 +2,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
@ -22,6 +23,7 @@ import (
"verstak/internal/core/plugins"
"verstak/internal/core/search"
"verstak/internal/core/storage"
"verstak/internal/core/util"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/worklog"
)
@ -60,6 +62,7 @@ func (a *App) autoSyncLoop() {
ticker := time.NewTicker(checkInterval)
defer ticker.Stop()
log.Printf("[autosync] started, vault=%s", a.vault)
var lastSync time.Time
for {
select {
case <-ticker.C:
@ -74,21 +77,27 @@ func (a *App) autoSyncLoop() {
serverURL = sURL
}
if serverURL == "" {
log.Printf("[autosync] no server URL")
continue
}
if cfg != nil && cfg.Sync.SyncInterval <= 0 {
log.Printf("[autosync] interval=%d, skipping", cfg.Sync.SyncInterval)
interval := 0
if cfg != nil {
interval = cfg.Sync.SyncInterval
}
if interval <= 0 {
continue
}
if !lastSync.IsZero() && time.Since(lastSync) < time.Duration(interval)*time.Minute {
continue
}
deviceToken := config.LoadDeviceToken(a.vault)
if deviceToken == "" {
log.Printf("[autosync] no device token")
continue
}
log.Printf("[autosync] running SyncNow...")
if _, err := a.SyncNow(); err != nil {
log.Printf("[autosync] SyncNow error: %v", err)
} else {
lastSync = time.Now()
}
case <-a.ctx.Done():
log.Printf("[autosync] stopped")
@ -433,11 +442,68 @@ func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, e
return nil, err
}
_ = a.activity.Record(n.ID, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, "")
_ = a.sync.RecordOp(syncsvc.EntityNode, n.ID, syncsvc.OpCreate, map[string]string{"title": title})
_ = a.sync.RecordOp(syncsvc.EntityNode, n.ID, syncsvc.OpCreate, nodePayload(n))
dto := toNodeDTO(n)
return &dto, nil
}
func nodePayload(n *nodes.Node) map[string]interface{} {
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
return map[string]interface{}{
"id": n.ID,
"parent_id": pid,
"type": n.Type,
"title": n.Title,
"slug": n.Slug,
"section": n.Section,
"sort_order": n.SortOrder,
"created_at": n.CreatedAt.Format(time.RFC3339),
"updated_at": n.UpdatedAt.Format(time.RFC3339),
}
}
func (a *App) filePayload(n *nodes.Node) map[string]interface{} {
p := map[string]interface{}{
"node_id": n.ID,
"type": n.Type,
"title": n.Title,
"slug": n.Slug,
"created_at": n.CreatedAt.Format(time.RFC3339),
"updated_at": n.UpdatedAt.Format(time.RFC3339),
}
if n.ParentID != nil {
p["parent_id"] = *n.ParentID
}
// Look up the linked file record, if any.
if recs, err := a.files.ListByNode(n.ID); err == nil && len(recs) > 0 {
rec := recs[0]
p["filename"] = rec.Filename
p["path"] = rec.Path
p["storage_mode"] = rec.StorageMode
p["size"] = rec.Size
p["sha256"] = rec.SHA256
p["mime"] = rec.MIME
p["file_id"] = rec.ID
// Compute blob SHA-256 for vault files.
if rec.StorageMode == "vault" {
if rec.SHA256 != "" {
p["blob_sha256"] = rec.SHA256
} else {
absPath := filepath.Join(a.vault, rec.Path)
if hash, err := syncsvc.HashFile(absPath); err == nil {
p["blob_sha256"] = hash
}
}
}
} else {
p["filename"] = n.Title
}
return p
}
func (a *App) DeleteNode(id string) error {
return a.nodes.SoftDelete(id)
}
@ -523,16 +589,30 @@ func (a *App) ListNotes(nodeID string) ([]NodeDTO, error) {
// CreateNote creates a note under a parent node.
func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
node, _, err := a.notes.Create(parentID, title, "")
node, fileRec, err := a.notes.Create(parentID, title, "")
if err != nil {
return nil, err
}
content, _ := a.notes.Read(node.ID)
_ = a.activity.Record(parentID, activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, "")
_ = a.sync.RecordOp(syncsvc.EntityNote, node.ID, syncsvc.OpCreate, map[string]string{"title": title})
_ = a.sync.RecordOp(syncsvc.EntityNote, node.ID, syncsvc.OpCreate, notePayload(node, fileRec, content))
dto := toNodeDTO(node)
return &dto, nil
}
func notePayload(node *nodes.Node, fileRec *files.Record, content string) map[string]interface{} {
return map[string]interface{}{
"node_id": node.ID,
"file_id": fileRec.ID,
"format": "markdown",
"content": content,
"filename": fileRec.Filename,
"path": fileRec.Path,
"created_at": node.CreatedAt.Format(time.RFC3339),
"updated_at": node.UpdatedAt.Format(time.RFC3339),
}
}
// ReadNote reads note content.
func (a *App) ReadNote(noteID string) (string, error) {
return a.notes.Read(noteID)
@ -550,7 +630,11 @@ func (a *App) SaveNote(noteID, content string) error {
pid = *n.ParentID
}
_ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteUpdated, n.Title, "")
_ = a.sync.RecordOp(syncsvc.EntityNote, noteID, syncsvc.OpUpdate, map[string]string{"title": n.Title})
_ = a.sync.RecordOp(syncsvc.EntityNote, noteID, syncsvc.OpUpdate, map[string]interface{}{
"node_id": noteID,
"content": content,
"updated_at": time.Now().UTC().Format(time.RFC3339),
})
}
return nil
}
@ -623,7 +707,7 @@ func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
}
for _, n := range nodes {
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, map[string]string{"title": n.Title})
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, a.filePayload(&n))
}
return toNodeDTOs(nodes), nil
}
@ -635,7 +719,7 @@ func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
}
for _, n := range nodes {
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, map[string]string{"title": n.Title})
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, a.filePayload(&n))
}
return toNodeDTOs(nodes), nil
}
@ -669,7 +753,7 @@ func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
return nil, err
}
_ = a.activity.Record(parentID, activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, "")
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, map[string]string{"title": filename})
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, a.filePayload(node))
dto := toNodeDTO(node)
return &dto, nil
}
@ -686,7 +770,7 @@ func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
pid = *n.ParentID
}
_ = a.activity.Record(pid, activity.TargetFile, node.ID, "", activity.TypeFileCopied, node.Title, "")
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, map[string]string{"title": node.Title})
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, a.filePayload(node))
dto := toNodeDTO(node)
return &dto, nil
}
@ -715,7 +799,10 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
if n.Type == nodes.TypeFolder {
syncEntity = syncsvc.EntityFolder
}
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpUpdate, map[string]string{"title": newTitle})
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpUpdate, map[string]interface{}{
"title": newTitle,
"updated_at": time.Now().UTC().Format(time.RFC3339),
})
return nil
}
@ -751,7 +838,10 @@ func (a *App) MoveNode(nodeID, newParentID string) error {
pid = *node.ParentID
}
_ = a.activity.Record(pid, activity.TargetFile, nodeID, "", activity.TypeFileMoved, node.Title, `{"to":"`+newParentID+`"}`)
_ = a.sync.RecordOp(syncsvc.EntityFile, nodeID, syncsvc.OpMove, map[string]string{"title": node.Title})
_ = a.sync.RecordOp(syncsvc.EntityFile, nodeID, syncsvc.OpMove, map[string]interface{}{
"parent_id": newParentID,
"updated_at": time.Now().UTC().Format(time.RFC3339),
})
return nil
}
@ -790,7 +880,7 @@ func (a *App) CreateAction(nodeID, kind, title, data string) (*ActionDTO, error)
if err != nil {
return nil, err
}
_ = a.sync.RecordOp(syncsvc.EntityAction, rec.ID, syncsvc.OpCreate, map[string]string{"title": rec.Title, "kind": rec.Kind})
_ = a.sync.RecordOp(syncsvc.EntityAction, rec.ID, syncsvc.OpCreate, actionPayload(rec))
return &ActionDTO{
ID: rec.ID,
NodeID: rec.NodeID,
@ -800,6 +890,23 @@ func (a *App) CreateAction(nodeID, kind, title, data string) (*ActionDTO, error)
}, nil
}
func actionPayload(rec *actions.Record) map[string]interface{} {
return map[string]interface{}{
"id": rec.ID,
"node_id": rec.NodeID,
"title": rec.Title,
"kind": rec.Kind,
"command": rec.Command,
"args": rec.Args,
"working_dir": rec.WorkingDir,
"url": rec.URL,
"confirm_required": rec.ConfirmRequired,
"capture_output": rec.CaptureOutput,
"created_at": rec.CreatedAt.Format(time.RFC3339),
"updated_at": rec.UpdatedAt.Format(time.RFC3339),
}
}
func (a *App) DeleteAction(id string) error {
_ = a.sync.RecordOp(syncsvc.EntityAction, id, syncsvc.OpDelete, nil)
return a.actions.Delete(id)
@ -841,7 +948,7 @@ func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, e
if err != nil {
return nil, err
}
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, map[string]string{"summary": summary})
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
mins := 0
if entry.Minutes != nil {
mins = *entry.Minutes
@ -856,6 +963,32 @@ func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, e
return dto, nil
}
func worklogPayload(entry *worklog.Entry) map[string]interface{} {
mins := 0
if entry.Minutes != nil {
mins = *entry.Minutes
}
p := map[string]interface{}{
"id": entry.ID,
"node_id": entry.NodeID,
"summary": entry.Summary,
"details": entry.Details,
"minutes": mins,
"date": entry.Date,
"approximate": entry.Approximate,
"billable": entry.Billable,
"created_at": entry.CreatedAt.Format(time.RFC3339),
"updated_at": entry.UpdatedAt.Format(time.RFC3339),
}
if entry.StartedAt != nil {
p["started_at"] = entry.StartedAt.Format(time.RFC3339)
}
if entry.EndedAt != nil {
p["ended_at"] = entry.EndedAt.Format(time.RFC3339)
}
return p
}
// ============================================================
// Search
// ============================================================
@ -988,9 +1121,9 @@ func (a *App) SyncDisconnect() error {
}
func (a *App) SyncTestConnection(serverURL, username, password string) error {
// Use a dedicated auth test that does NOT create a device.
client := syncsvc.NewClient(serverURL, "", "", a.vault)
_, _, err := client.PairDevice(serverURL, username, password, "test-connection", "verstak-gui/v2")
return err
return client.TestAuth(serverURL, username, password)
}
func (a *App) SyncSetInterval(minutes int) error {
@ -1027,11 +1160,14 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
client := syncsvc.NewClient(serverURL, apiKey, deviceID, a.vault)
client.DeviceToken = deviceToken
// Push unpushed ops.
// Push unpushed ops — set last_seen_server_seq on each.
unpushed, err := a.sync.GetUnpushedOps()
if err != nil {
return nil, fmt.Errorf("get ops: %w", err)
}
for i := range unpushed {
unpushed[i].LastSeenServerSeq = lastPullSeq
}
pushResult := &syncsvc.PushResponse{}
if len(unpushed) > 0 {
pushResult, err = client.Push(unpushed)
@ -1049,11 +1185,15 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
return nil, fmt.Errorf("pull: %w", err)
}
if len(pullResult.Ops) > 0 {
// Apply pulled ops locally (record as remote ops, mark applied).
// Apply each pulled op to the local vault.
var applyErrors []string
for _, op := range pullResult.Ops {
if err := a.applyRemoteOp(op); err != nil {
applyErrors = append(applyErrors, fmt.Sprintf("%s/%s: %v", op.EntityType, op.OpID, err))
}
_ = a.sync.RecordRemoteOp(op)
}
if len(pullResult.Ops) > 0 {
opIDs := make([]string, len(pullResult.Ops))
for i, op := range pullResult.Ops {
opIDs[i] = op.OpID
@ -1061,17 +1201,523 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
_ = a.sync.MarkApplied(opIDs)
}
// Report conflicts.
if len(pushResult.Conflicts) > 0 {
log.Printf("[sync] %d conflict(s) detected on push", len(pushResult.Conflicts))
for _, c := range pushResult.Conflicts {
log.Printf("[sync] conflict: op=%v entity=%v/%v",
c["op_id"], c["entity_type"], c["entity_id"])
}
}
// Update sync state.
if pullResult.ServerSequence > lastPullSeq {
_ = a.sync.SetLastPullSeq(pullResult.ServerSequence)
}
_ = a.sync.SetLastSyncAt(time.Now().UTC().Format(time.RFC3339))
return map[string]interface{}{
result := map[string]interface{}{
"pushed": len(pushResult.Accepted),
"pulled": len(pullResult.Ops),
"serverSequence": pullResult.ServerSequence,
}, nil
}
if len(applyErrors) > 0 {
result["applyErrors"] = applyErrors
}
if len(pushResult.Conflicts) > 0 {
result["conflicts"] = pushResult.Conflicts
}
return result, nil
}
// applyRemoteOp dispatches a remote sync operation to the correct entity handler.
func (a *App) applyRemoteOp(op syncsvc.Op) error {
switch op.EntityType {
case syncsvc.EntityNode:
return a.applyRemoteNodeOp(op)
case syncsvc.EntityNote:
return a.applyRemoteNoteOp(op)
case syncsvc.EntityFile, syncsvc.EntityFolder:
return a.applyRemoteFileOrFolderOp(op)
case syncsvc.EntityAction:
return a.applyRemoteActionOp(op)
case syncsvc.EntityWorklog:
return a.applyRemoteWorklogOp(op)
}
return nil // unknown entity type, skip silently
}
// --- apply helpers ---
func (a *App) applyRemoteNodeOp(op syncsvc.Op) error {
switch op.OpType {
case syncsvc.OpCreate:
return a.applyRemoteNodeCreate(op)
case syncsvc.OpUpdate:
return a.applyRemoteNodeUpdate(op)
case syncsvc.OpMove:
return a.applyRemoteNodeMove(op)
case syncsvc.OpDelete:
return a.applyRemoteNodeDelete(op)
}
return nil
}
func (a *App) applyRemoteNodeCreate(op syncsvc.Op) error {
var payload struct {
ID string `json:"id"`
ParentID string `json:"parent_id"`
Type string `json:"type"`
Title string `json:"title"`
Slug string `json:"slug"`
Section string `json:"section"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
return fmt.Errorf("unmarshal node create: %w", err)
}
if payload.ID == "" || payload.Type == "" || payload.Title == "" {
return fmt.Errorf("incomplete node payload")
}
// Check if node already exists (e.g., created by a prior file/note op).
if _, err := a.nodes.Get(payload.ID); err == nil {
return nil // already exists
}
// Insert directly (bypass slug uniqueness / validation for remote ops).
now := time.Now().UTC().Format(time.RFC3339)
if payload.CreatedAt == "" {
payload.CreatedAt = now
}
if payload.UpdatedAt == "" {
payload.UpdatedAt = now
}
var parent interface{}
if payload.ParentID != "" {
parent = payload.ParentID
}
var section interface{}
if payload.Section != "" {
section = payload.Section
}
slug := payload.Slug
if slug == "" {
slug = nodes.Slugify(payload.Title)
}
_, err := a.db.Exec(
`INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,section,sort_order,created_at,updated_at,revision,device_id)
VALUES (?,?,?,?,?,?,0,?,?,1,NULL)`,
payload.ID, parent, payload.Type, payload.Title, slug, section,
payload.CreatedAt, payload.UpdatedAt,
)
return err
}
func (a *App) applyRemoteNodeUpdate(op syncsvc.Op) error {
var payload struct {
Title string `json:"title"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
return fmt.Errorf("unmarshal node update: %w", err)
}
now := time.Now().UTC().Format(time.RFC3339)
if payload.UpdatedAt != "" {
now = payload.UpdatedAt
}
if payload.Title != "" {
slug := nodes.Slugify(payload.Title)
_, err := a.db.Exec(
`UPDATE nodes SET title=?, slug=?, updated_at=? WHERE id=?`,
payload.Title, slug, now, op.EntityID)
return err
}
// No title = just touch.
_, err := a.db.Exec(`UPDATE nodes SET updated_at=? WHERE id=?`, now, op.EntityID)
return err
}
func (a *App) applyRemoteNodeMove(op syncsvc.Op) error {
var payload struct {
ParentID string `json:"parent_id"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
return fmt.Errorf("unmarshal node move: %w", err)
}
now := time.Now().UTC().Format(time.RFC3339)
if payload.UpdatedAt != "" {
now = payload.UpdatedAt
}
var parent interface{}
if payload.ParentID != "" {
parent = payload.ParentID
}
_, err := a.db.Exec(
`UPDATE nodes SET parent_id=?, updated_at=? WHERE id=?`,
parent, now, op.EntityID)
return err
}
func (a *App) applyRemoteNodeDelete(op syncsvc.Op) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := a.db.Exec(
`UPDATE nodes SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
now, now, op.EntityID)
return err
}
func (a *App) applyRemoteNoteOp(op syncsvc.Op) error {
switch op.OpType {
case syncsvc.OpCreate:
return a.applyRemoteNoteCreate(op)
case syncsvc.OpUpdate:
return a.applyRemoteNoteUpdate(op)
case syncsvc.OpDelete:
return a.applyRemoteNodeDelete(op)
}
return nil
}
func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error {
var payload struct {
NodeID string `json:"node_id"`
FileID string `json:"file_id"`
Format string `json:"format"`
Content string `json:"content"`
Filename string `json:"filename"`
Path string `json:"path"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
return fmt.Errorf("unmarshal note create: %w", err)
}
if payload.NodeID == "" {
return nil
}
now := time.Now().UTC().Format(time.RFC3339)
// Ensure the parent node exists (create a placeholder if not).
if _, err := a.nodes.Get(payload.NodeID); err != nil {
slug := nodes.Slugify("remote-note")
_, e := a.db.Exec(
`INSERT OR IGNORE INTO nodes (id,type,title,slug,created_at,updated_at,revision)
VALUES (?,'note','remote-note',?,?,?,1)`,
payload.NodeID, slug, now, now)
if e != nil {
return e
}
}
// Write the .md file.
dest := filepath.Join(a.vault, payload.Path)
if payload.Path == "" {
filename := payload.Filename
if filename == "" {
filename = payload.NodeID[:8] + ".md"
}
dest = filepath.Join(a.vault, "spaces", filename)
payload.Path, _ = filepath.Rel(a.vault, dest)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o750); err != nil {
return err
}
if err := os.WriteFile(dest, []byte(payload.Content), 0o640); err != nil {
return err
}
info, _ := os.Stat(dest)
size := int64(0)
if info != nil {
size = info.Size()
}
// Create file record.
fileID := payload.FileID
if fileID == "" {
fileID = util.UUID7()
}
_, err := a.db.Exec(
`INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,'vault',?,'text/plain',?,?,0)`,
fileID, payload.NodeID, filepath.Base(dest), payload.Path, size, now, now)
if err != nil {
return err
}
// Create notes link.
format := payload.Format
if format == "" {
format = "markdown"
}
_, err = a.db.Exec(
`INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
payload.NodeID, fileID, format)
return err
}
func (a *App) applyRemoteNoteUpdate(op syncsvc.Op) error {
var payload struct {
NodeID string `json:"node_id"`
Content string `json:"content"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
return fmt.Errorf("unmarshal note update: %w", err)
}
if payload.NodeID == "" {
return nil
}
// Find the note's file path.
var filePath, storageMode string
err := a.db.QueryRow(
`SELECT f.path, f.storage_mode FROM notes n JOIN files f ON n.file_id = f.id WHERE n.node_id=?`,
payload.NodeID).Scan(&filePath, &storageMode)
if err != nil {
return fmt.Errorf("note record not found: %w", err)
}
var abs string
if storageMode == "vault" {
abs = filepath.Join(a.vault, filePath)
} else {
abs = filePath
}
if err := os.WriteFile(abs, []byte(payload.Content), 0o640); err != nil {
return err
}
info, _ := os.Stat(abs)
size := int64(0)
if info != nil {
size = info.Size()
}
now := time.Now().UTC().Format(time.RFC3339)
_, e := a.db.Exec(
`UPDATE files SET size=?, updated_at=? WHERE path=? AND storage_mode=?`,
size, now, filePath, storageMode)
return e
}
func (a *App) applyRemoteFileOrFolderOp(op syncsvc.Op) error {
switch op.OpType {
case syncsvc.OpCreate:
return a.applyRemoteFileCreate(op)
case syncsvc.OpUpdate:
return a.applyRemoteNodeUpdate(op)
case syncsvc.OpMove:
return a.applyRemoteNodeMove(op)
case syncsvc.OpDelete:
return a.applyRemoteNodeDelete(op)
}
return nil
}
func (a *App) applyRemoteFileCreate(op syncsvc.Op) error {
var payload struct {
NodeID string `json:"node_id"`
Type string `json:"type"`
Title string `json:"title"`
Slug string `json:"slug"`
ParentID string `json:"parent_id"`
Filename string `json:"filename"`
Path string `json:"path"`
StorageMode string `json:"storage_mode"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
MIME string `json:"mime"`
FileID string `json:"file_id"`
BlobSHA256 string `json:"blob_sha256"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
return fmt.Errorf("unmarshal file create: %w", err)
}
if payload.NodeID == "" {
return nil
}
now := time.Now().UTC().Format(time.RFC3339)
// Create the node if not exists.
if _, err := a.nodes.Get(payload.NodeID); err != nil {
slug := payload.Slug
if slug == "" {
slug = nodes.Slugify(payload.Title)
}
ntype := payload.Type
if ntype == "" {
ntype = "file"
}
var parent interface{}
if payload.ParentID != "" {
parent = payload.ParentID
}
_, e := a.db.Exec(
`INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,created_at,updated_at,revision)
VALUES (?,?,?,?,?,?,?,1)`,
payload.NodeID, parent, ntype, payload.Title, slug, now, now)
if e != nil {
return e
}
}
// Download blob if needed and not already present on disk.
if payload.BlobSHA256 != "" && payload.StorageMode == "vault" {
blobsDir := syncsvc.BlobDir(a.vault)
blobPath := syncsvc.BlobPath(blobsDir, payload.BlobSHA256)
if _, err := os.Stat(blobPath); os.IsNotExist(err) {
// Download from server.
serverURL, apiKey, _, _, _ := a.sync.GetState()
deviceToken := config.LoadDeviceToken(a.vault)
cli := syncsvc.NewClient(serverURL, apiKey, "", a.vault)
cli.DeviceToken = deviceToken
if err := cli.DownloadBlob(payload.BlobSHA256, blobPath); err != nil {
log.Printf("[sync] blob download failed for %s: %v", payload.BlobSHA256, err)
}
}
// Place file in vault.
dest := filepath.Join(a.vault, payload.Path)
if err := os.MkdirAll(filepath.Dir(dest), 0o750); err == nil {
// Copy blob to actual vault location.
input, rErr := os.ReadFile(blobPath)
if rErr == nil {
_ = os.WriteFile(dest, input, 0o640)
}
}
}
// Create file record.
fileID := payload.FileID
if fileID == "" {
fileID = util.UUID7()
}
storageMode := payload.StorageMode
if storageMode == "" {
storageMode = "vault"
}
mime := payload.MIME
if mime == "" {
mime = "application/octet-stream"
}
_, err := a.db.Exec(
`INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,?,?,?,?,?,?,0)`,
fileID, payload.NodeID, payload.Filename, payload.Path, storageMode,
payload.Size, payload.SHA256, mime, now, now)
return err
}
func (a *App) applyRemoteActionOp(op syncsvc.Op) error {
switch op.OpType {
case syncsvc.OpCreate:
return a.applyRemoteActionCreate(op)
case syncsvc.OpDelete:
_, err := a.db.Exec(`DELETE FROM actions WHERE id=?`, op.EntityID)
return err
}
return nil
}
func (a *App) applyRemoteActionCreate(op syncsvc.Op) error {
var payload struct {
ID string `json:"id"`
NodeID string `json:"node_id"`
Title string `json:"title"`
Kind string `json:"kind"`
Command string `json:"command"`
Args []string `json:"args"`
WorkingDir string `json:"working_dir"`
URL string `json:"url"`
ConfirmRequired bool `json:"confirm_required"`
CaptureOutput bool `json:"capture_output"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
return fmt.Errorf("unmarshal action create: %w", err)
}
if payload.ID == "" || payload.NodeID == "" {
return nil
}
_, err := a.db.Exec(
`INSERT OR IGNORE INTO actions (id,node_id,title,kind,command,args_json,working_dir,url,confirm_required,capture_output,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
payload.ID, payload.NodeID, payload.Title, payload.Kind,
payload.Command, jsonArgs(payload.Args), payload.WorkingDir, payload.URL,
boolToInt(payload.ConfirmRequired), boolToInt(payload.CaptureOutput),
payload.CreatedAt, payload.UpdatedAt)
return err
}
func (a *App) applyRemoteWorklogOp(op syncsvc.Op) error {
switch op.OpType {
case syncsvc.OpCreate:
return a.applyRemoteWorklogCreate(op)
case syncsvc.OpDelete:
_, err := a.db.Exec(`DELETE FROM worklog_entries WHERE id=?`, op.EntityID)
return err
}
return nil
}
func (a *App) applyRemoteWorklogCreate(op syncsvc.Op) error {
var payload struct {
ID string `json:"id"`
NodeID string `json:"node_id"`
Summary string `json:"summary"`
Details string `json:"details"`
Minutes int `json:"minutes"`
Date string `json:"date"`
StartedAt string `json:"started_at"`
EndedAt string `json:"ended_at"`
Approximate bool `json:"approximate"`
Billable bool `json:"billable"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
return fmt.Errorf("unmarshal worklog create: %w", err)
}
if payload.ID == "" || payload.NodeID == "" {
return nil
}
_, err := a.db.Exec(
`INSERT OR IGNORE INTO worklog_entries (id,node_id,started_at,ended_at,date,minutes,approximate,billable,summary,details,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
payload.ID, payload.NodeID, strPtr(payload.StartedAt), strPtr(payload.EndedAt),
payload.Date, payload.Minutes, boolToInt(payload.Approximate), boolToInt(payload.Billable),
payload.Summary, payload.Details, payload.CreatedAt, payload.UpdatedAt)
return err
}
// --- small helpers ---
func jsonArgs(args []string) string {
if len(args) == 0 {
return ""
}
b, _ := json.Marshal(args)
return string(b)
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func strPtr(s string) interface{} {
if s == "" {
return nil
}
return s
}
// ============================================================

View File

@ -418,6 +418,7 @@ func (s *Server) routes() *http.ServeMux {
mux.HandleFunc("/api/v1/sync/pull", s.handleSyncPull)
mux.HandleFunc("/api/v1/blobs/", s.handleBlobs)
mux.HandleFunc("/api/client/pair", s.handleClientPair)
mux.HandleFunc("/api/auth/test", s.handleAuthTest)
mux.HandleFunc("/api/client/revoke-current", s.handleClientRevoke)
mux.HandleFunc("/api/client/me", s.handleClientMe)
mux.HandleFunc("/api/client/revoke-device", s.handleClientRevokeDevice)
@ -827,6 +828,46 @@ func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
})
}
func (s *Server) handleAuthTest(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "bad json")
return
}
if req.Username == "" || req.Password == "" {
jsonErr(w, 400, "username and password required")
return
}
var hash string
var confirmed, blocked int
err := s.db.QueryRow("SELECT password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
req.Username, strings.ToLower(req.Username)).Scan(&hash, &confirmed, &blocked)
if err != nil {
jsonErr(w, 401, "invalid credentials")
return
}
if blocked != 0 {
jsonErr(w, 403, "account blocked")
return
}
if confirmed == 0 {
jsonErr(w, 403, "email not confirmed")
return
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
jsonErr(w, 401, "invalid credentials")
return
}
jsonOK(w, map[string]string{"status": "ok"})
}
func (s *Server) handleClientRevoke(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
@ -1698,7 +1739,7 @@ func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
html := fmt.Sprintf(resetPasswordHTML, token)
html := strings.ReplaceAll(resetPasswordHTML, "{TOKEN}", token)
w.Write([]byte(html))
case "POST":
if err := r.ParseForm(); err != nil {
@ -1941,7 +1982,7 @@ func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
smtpSecurity := s.smtpGet("smtp_security")
srvURL := s.smtpGet("server_url")
html := fmt.Sprintf(`<!DOCTYPE html>
html := `<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync Admin</title>
@ -2060,8 +2101,14 @@ function testSMTP(){
<pre id="health-result">Загрузка...</pre>
</div>
</div>
_ = smtpURL
_ = smtpUser
_ = smtpFrom
_ = smtpSecurity
_ = smtpHost
_ = smtpPort
</body></html>`)
</body></html>`
w.Write([]byte(html))
}
@ -2762,7 +2809,7 @@ button:hover{background:#4f46e5}
</head><body>
<form method="POST">
<h1>Новый пароль</h1>
<input type="hidden" name="token" value="%s">
<input type="hidden" name="token" value="{TOKEN}">
<label>Новый пароль</label>
<input type="password" name="password" minlength="8" required autofocus>
<label>Подтвердите пароль</label>

View File

@ -672,6 +672,11 @@ func runSyncPush(args []string) {
return
}
_, _, lastSeq, _, _ := syncSvc.GetState()
for i := range unpushed {
unpushed[i].LastSeenServerSeq = lastSeq
}
result, err := client.Push(unpushed)
if err != nil {
fmt.Fprintf(os.Stderr, "Push failed: %v\n", err)
@ -684,6 +689,9 @@ func runSyncPush(args []string) {
}
fmt.Printf("Pushed %d ops, accepted %d\n", len(unpushed), len(result.Accepted))
if len(result.Conflicts) > 0 {
fmt.Printf("WARNING: %d conflict(s) detected\n", len(result.Conflicts))
}
}
func runSyncPull(args []string) {

View File

@ -130,6 +130,19 @@ func (c *Client) Login(username, password string) (token string, err error) {
return resp.Token, nil
}
// TestAuth checks credentials without creating a device.
func (c *Client) TestAuth(serverURL, username, password string) error {
body := map[string]string{"username": username, "password": password}
savedURL := c.ServerURL
savedKey := c.APIKey
c.ServerURL = serverURL
c.APIKey = ""
err := c.post("/api/auth/test", body, nil)
c.ServerURL = savedURL
c.APIKey = savedKey
return err
}
// PushRequest is the payload for POST /sync/push.
type PushRequest struct {
DeviceID string `json:"device_id"`
@ -166,6 +179,8 @@ func (c *Client) Push(ops []Op) (*PushResponse, error) {
EntityID: op.EntityID,
OpType: op.OpType,
PayloadJSON: op.PayloadJSON,
ClientSequence: op.ClientSequence,
LastSeenServerSeq: op.LastSeenServerSeq,
CreatedAt: op.CreatedAt,
}
}

View File

@ -40,6 +40,8 @@ type Op struct {
PayloadJSON string `json:"payload_json"`
CreatedAt string `json:"created_at"`
PushedAt *string `json:"pushed_at,omitempty"`
ClientSequence int `json:"client_sequence,omitempty"`
LastSeenServerSeq int `json:"last_seen_server_seq,omitempty"`
}
// Service records and manages sync operations.

View File

@ -0,0 +1,311 @@
package sync
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
_ "github.com/mattn/go-sqlite3"
)
// TestE2ESync starts a real server process and tests two-client sync.
func TestE2ESync(t *testing.T) {
if testing.Short() {
t.Skip("skipping e2e sync test in short mode")
}
// Find the server binary (must be pre-built).
serverBin := findServerBin(t)
if serverBin == "" {
t.Skip("verstak-server binary not found; build with 'go build -o verstak-server ./cmd/verstak-server'")
}
// Create temp directories.
serverDir := t.TempDir()
vaultA := t.TempDir()
vaultB := t.TempDir()
// Init vaults.
os.MkdirAll(filepath.Join(vaultA, ".verstak"), 0750)
os.MkdirAll(filepath.Join(vaultB, ".verstak"), 0750)
// Pick a random port.
serverPort := pickPort(t)
// Start server.
serverDataDir := filepath.Join(serverDir, "data")
os.MkdirAll(serverDataDir, 0750)
cmd := exec.Command(serverBin,
"-port", fmt.Sprintf("%d", serverPort),
"-data", serverDataDir,
"-admin-user", "admin",
"-admin-pass", "admin",
)
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatalf("start server: %v", err)
}
defer cmd.Process.Kill()
// Wait for server.
serverURL := fmt.Sprintf("http://127.0.0.1:%d", serverPort)
waitForServer(t, serverURL, 10*time.Second)
// Register user.
regBody := fmt.Sprintf(`{"username":"testuser","email":"test@test.com","password":"password123"}`)
resp, err := http.Post(serverURL+"/api/v1/auth/register", "application/json", strings.NewReader(regBody))
if err != nil {
t.Fatalf("register: %v", err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
// Confirm email by directly updating the server DB.
dbPath := filepath.Join(serverDataDir, "server.db")
confirmUser(t, dbPath, "testuser")
// Test auth (the new endpoint).
testBody := fmt.Sprintf(`{"username":"testuser","password":"password123"}`)
resp, err = http.Post(serverURL+"/api/auth/test", "application/json", strings.NewReader(testBody))
if err != nil {
t.Fatalf("auth test: %v", err)
}
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Fatalf("auth test status %d: %s", resp.StatusCode, string(body))
}
resp.Body.Close()
// Pair device A.
pairBody := fmt.Sprintf(`{"login":"testuser","password":"password123","device_name":"Client A","client_version":"test/v1"}`)
resp, err = http.Post(serverURL+"/api/client/pair", "application/json", strings.NewReader(pairBody))
if err != nil {
t.Fatalf("pair A: %v", err)
}
var pairResp struct {
DeviceID string `json:"device_id"`
DeviceToken string `json:"device_token"`
UserID string `json:"user_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&pairResp); err != nil {
t.Fatalf("decode pair A: %v", err)
}
resp.Body.Close()
deviceIDA := pairResp.DeviceID
tokenA := pairResp.DeviceToken
t.Logf("Client A: device=%s token=%s...%s", deviceIDA, tokenA[:16], tokenA[len(tokenA)-8:])
// Pair device B.
pairBody = fmt.Sprintf(`{"login":"testuser","password":"password123","device_name":"Client B","client_version":"test/v1"}`)
resp, err = http.Post(serverURL+"/api/client/pair", "application/json", strings.NewReader(pairBody))
if err != nil {
t.Fatalf("pair B: %v", err)
}
var pairRespB struct {
DeviceID string `json:"device_id"`
DeviceToken string `json:"device_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&pairRespB); err != nil {
t.Fatalf("decode pair B: %v", err)
}
resp.Body.Close()
deviceIDB := pairRespB.DeviceID
tokenB := pairRespB.DeviceToken
t.Logf("Client B: device=%s token=%s...%s", deviceIDB, tokenB[:16], tokenB[len(tokenB)-8:])
// Now create a node on Client A by posting a sync op.
// Simulating what the GUI does: push a "node create" op.
nodeID := "test-node-001"
now := time.Now().UTC().Format(time.RFC3339)
pushPayload := map[string]interface{}{
"device_id": deviceIDA,
"ops": []map[string]interface{}{
{
"op_id": "op-node-create-001",
"entity_type": "node",
"entity_id": nodeID,
"op_type": "create",
"payload_json": fmt.Sprintf(
`{"id":"%s","parent_id":"","type":"case","title":"Test Project","slug":"test-project","section":"projects","created_at":"%s","updated_at":"%s"}`,
nodeID, now, now),
"client_sequence": 1,
"last_seen_server_seq": 0,
"created_at": now,
},
},
"idempotency_key": "e2e-test-push-1",
}
pushBody, _ := json.Marshal(pushPayload)
req, _ := http.NewRequest("POST", serverURL+"/api/v1/sync/push", bytes.NewReader(pushBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenA)
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("push A: %v", err)
}
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Fatalf("push A status %d: %s", resp.StatusCode, string(body))
}
var pushRespA struct {
Accepted []string `json:"accepted"`
Conflicts []interface{} `json:"conflicts"`
}
json.NewDecoder(resp.Body).Decode(&pushRespA)
resp.Body.Close()
t.Logf("Push A accepted: %v", pushRespA.Accepted)
// Pull from Client B — should get the node op.
pullReq := map[string]interface{}{"since_sequence": 0}
pullBody, _ := json.Marshal(pullReq)
req, _ = http.NewRequest("POST", serverURL+"/api/v1/sync/pull", bytes.NewReader(pullBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenB)
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("pull B: %v", err)
}
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Fatalf("pull B status %d: %s", resp.StatusCode, string(body))
}
var pullRespB struct {
ServerSequence int `json:"server_sequence"`
Ops []struct {
OpID string `json:"op_id"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"`
} `json:"ops"`
}
json.NewDecoder(resp.Body).Decode(&pullRespB)
resp.Body.Close()
if len(pullRespB.Ops) == 0 {
t.Fatal("Client B pulled 0 ops, expected at least 1")
}
t.Logf("Client B pulled %d ops (server seq=%d)", len(pullRespB.Ops), pullRespB.ServerSequence)
foundNodeOp := false
for _, op := range pullRespB.Ops {
if op.EntityType == "node" && op.EntityID == nodeID && op.OpType == "create" {
foundNodeOp = true
// Verify payload contents.
var payload map[string]interface{}
json.Unmarshal([]byte(op.PayloadJSON), &payload)
if payload["title"] != "Test Project" {
t.Errorf("expected title 'Test Project', got %v", payload["title"])
}
if payload["type"] != "case" {
t.Errorf("expected type 'case', got %v", payload["type"])
}
if payload["section"] != "projects" {
t.Errorf("expected section 'projects', got %v", payload["section"])
}
t.Logf("Node payload verified: title=%v type=%v section=%v",
payload["title"], payload["type"], payload["section"])
}
}
if !foundNodeOp {
t.Errorf("node create op not found in pulled ops")
}
// Test the auth test endpoint doesn't create devices.
// Count devices before via direct DB access.
beforeCount := countDevices(t, dbPath)
t.Logf("Devices before auth test calls: %d (should be 2: A + B)", beforeCount)
// Call test auth multiple times.
for i := 0; i < 3; i++ {
testBody := fmt.Sprintf(`{"username":"testuser","password":"password123"}`)
resp, err = http.Post(serverURL+"/api/auth/test", "application/json", strings.NewReader(testBody))
if err != nil {
t.Fatalf("auth test iteration %d: %v", i, err)
}
resp.Body.Close()
}
// Count devices after — should be same.
afterCount := countDevices(t, dbPath)
if afterCount != beforeCount {
t.Errorf("device count changed after auth test: before=%d after=%d (should be equal)", beforeCount, afterCount)
}
t.Log("E2E sync test passed!")
}
func findServerBin(t *testing.T) string {
t.Helper()
// Check common locations.
candidates := []string{
"verstak-server",
"../../../verstak-server",
}
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
return c
}
}
return ""
}
func pickPort(t *testing.T) int {
t.Helper()
// Try a few ports — for testing, a fixed port is OK since tests use temp dirs.
return 18999
}
func waitForServer(t *testing.T, url string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := http.Get(url + "/api/v1/health")
if err == nil {
resp.Body.Close()
return
}
time.Sleep(200 * time.Millisecond)
}
t.Fatalf("server not ready within %v at %s", timeout, url)
}
func confirmUser(t *testing.T, dbPath, username string) {
t.Helper()
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("open server db for confirm: %v", err)
}
defer db.Close()
_, err = db.Exec("UPDATE server_users SET confirmed=1 WHERE username=?", username)
if err != nil {
t.Fatalf("confirm user: %v", err)
}
}
func countDevices(t *testing.T, dbPath string) int {
t.Helper()
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("open server db for count: %v", err)
}
defer db.Close()
var count int
db.QueryRow("SELECT COUNT(*) FROM server_devices").Scan(&count)
return count
}