diff --git a/internal/core/sync/sync_e2e_test.go b/internal/core/sync/sync_e2e_test.go new file mode 100644 index 0000000..75ddfc3 --- /dev/null +++ b/internal/core/sync/sync_e2e_test.go @@ -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 +}