verstak/internal/core/sync/client_test.go

386 lines
10 KiB
Go

package sync
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// mockSyncServer returns a handler that simulates the Verstak Sync Server.
func mockSyncServer(t *testing.T, handler func(w http.ResponseWriter, r *http.Request)) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
handler(w, r)
}))
}
func jsonOK(w http.ResponseWriter, v interface{}) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(v)
}
func jsonErr(w http.ResponseWriter, code int, msg string) {
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
func TestClient_PairDevice(t *testing.T) {
srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
if r.URL.Path != "/api/client/pair" {
jsonErr(w, 404, "not found")
return
}
body, _ := io.ReadAll(r.Body)
var req map[string]string
json.Unmarshal(body, &req)
if req["login"] != "testuser" || req["password"] != "pass" {
jsonErr(w, 401, "invalid credentials")
return
}
jsonOK(w, map[string]interface{}{
"device_id": "dev-test-001",
"device_token": "tok_secret_abc",
})
})
defer srv.Close()
c := NewClient(srv.URL, "", "", "")
devID, devToken, err := c.PairDevice(srv.URL, "testuser", "pass", "TestClient", "test/v1")
if err != nil {
t.Fatalf("PairDevice: %v", err)
}
if devID != "dev-test-001" {
t.Errorf("device_id = %q, want %q", devID, "dev-test-001")
}
if devToken != "tok_secret_abc" {
t.Errorf("device_token = %q, want %q", devToken, "tok_secret_abc")
}
}
func TestClient_PairDevice_InvalidCredentials(t *testing.T) {
srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 401, "invalid credentials")
})
defer srv.Close()
c := NewClient(srv.URL, "", "", "")
_, _, err := c.PairDevice(srv.URL, "bad", "wrong", "Test", "v1")
if err == nil {
t.Fatal("expected error for invalid credentials")
}
if !strings.Contains(err.Error(), "401") {
t.Errorf("expected 401 error, got: %v", err)
}
}
func TestClient_Push(t *testing.T) {
srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/sync/push" {
jsonErr(w, 404, "not found")
return
}
// Verify authorization header.
auth := r.Header.Get("Authorization")
if auth != "Bearer test-token" {
jsonErr(w, 401, "invalid token")
return
}
jsonOK(w, map[string]interface{}{
"accepted": []string{"op-1"},
"count": 1,
})
})
defer srv.Close()
c := NewClient(srv.URL, "test-token", "dev-1", "/tmp/vault")
resp, err := c.Push([]Op{
{OpID: "op-1", EntityType: EntityNode, EntityID: "node-1", OpType: OpCreate, PayloadJSON: `{}`},
})
if err != nil {
t.Fatalf("Push: %v", err)
}
if len(resp.Accepted) != 1 || resp.Accepted[0] != "op-1" {
t.Errorf("accepted = %v, want [op-1]", resp.Accepted)
}
if resp.Count != 1 {
t.Errorf("count = %d, want 1", resp.Count)
}
}
func TestClient_Push_Empty(t *testing.T) {
srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) {
jsonOK(w, map[string]interface{}{
"accepted": []string{},
"count": 0,
})
})
defer srv.Close()
c := NewClient(srv.URL, "tok", "dev-1", "/tmp/vault")
resp, err := c.Push([]Op{})
if err != nil {
t.Fatalf("Push empty: %v", err)
}
if resp.Count != 0 {
t.Errorf("count = %d, want 0", resp.Count)
}
}
func TestClient_Pull(t *testing.T) {
srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/sync/pull" {
jsonErr(w, 404, "not found")
return
}
jsonOK(w, map[string]interface{}{
"server_sequence": 5,
"ops": []map[string]interface{}{
{
"op_id": "remote-1",
"entity_type": EntityNode,
"entity_id": "node-99",
"op_type": OpCreate,
"payload_json": `{"title":"Remote Node"}`,
"created_at": "2025-01-01T00:00:00Z",
},
},
})
})
defer srv.Close()
c := NewClient(srv.URL, "tok", "dev-1", "/tmp/vault")
resp, err := c.Pull(0)
if err != nil {
t.Fatalf("Pull: %v", err)
}
if resp.ServerSequence != 5 {
t.Errorf("server_sequence = %d, want 5", resp.ServerSequence)
}
if len(resp.Ops) != 1 {
t.Fatalf("expected 1 op, got %d", len(resp.Ops))
}
if resp.Ops[0].EntityID != "node-99" {
t.Errorf("EntityID = %q, want %q", resp.Ops[0].EntityID, "node-99")
}
}
func TestClient_Pull_SinceSequence(t *testing.T) {
srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req PullRequest
json.Unmarshal(body, &req)
// Return ops only if since_sequence matches expected.
if req.SinceSequence != 10 {
jsonOK(w, map[string]interface{}{
"server_sequence": 10,
"ops": []interface{}{},
})
return
}
jsonOK(w, map[string]interface{}{
"server_sequence": 15,
"ops": []map[string]interface{}{
{"op_id": "new-op", "entity_type": EntityNode, "entity_id": "n1", "op_type": OpUpdate, "payload_json": "{}", "created_at": "2025-01-01T00:00:00Z"},
},
})
})
defer srv.Close()
c := NewClient(srv.URL, "tok", "dev-1", "/tmp/vault")
resp, err := c.Pull(10)
if err != nil {
t.Fatalf("Pull(10): %v", err)
}
if len(resp.Ops) != 1 {
t.Errorf("expected 1 op for seq=10, got %d", len(resp.Ops))
}
}
func TestClient_UploadBlob(t *testing.T) {
var receivedFile bool
srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/blobs/" {
jsonErr(w, 404, "not found")
return
}
receivedFile = true
jsonOK(w, map[string]interface{}{
"sha256": "test-sha-abc123",
"size": 4,
})
})
defer srv.Close()
dir := t.TempDir()
filePath := filepath.Join(dir, "upload.txt")
if err := os.WriteFile(filePath, []byte("data"), 0644); err != nil {
t.Fatal(err)
}
c := NewClient(srv.URL, "tok", "dev-1", "/tmp/vault")
sha, err := c.UploadBlob(filePath)
if err != nil {
t.Fatalf("UploadBlob: %v", err)
}
if sha != "test-sha-abc123" {
t.Errorf("sha = %q, want %q", sha, "test-sha-abc123")
}
if !receivedFile {
t.Error("UploadBlob did not reach server")
}
}
func TestClient_UploadBlob_NonExistent(t *testing.T) {
srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("server should not be called for non-existent file")
})
defer srv.Close()
c := NewClient(srv.URL, "tok", "dev-1", "/tmp/vault")
_, err := c.UploadBlob("/nonexistent/file.bin")
if err == nil {
t.Error("expected error for non-existent file")
}
}
func TestClient_DownloadBlob(t *testing.T) {
content := []byte("downloaded content")
srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/blobs/abcsha123" {
jsonErr(w, 404, "not found")
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusOK)
w.Write(content)
})
defer srv.Close()
dir := t.TempDir()
destPath := filepath.Join(dir, "downloaded.bin")
c := NewClient(srv.URL, "tok", "dev-1", "/tmp/vault")
if err := c.DownloadBlob("abcsha123", destPath); err != nil {
t.Fatalf("DownloadBlob: %v", err)
}
stored, err := os.ReadFile(destPath)
if err != nil {
t.Fatal(err)
}
if string(stored) != string(content) {
t.Errorf("downloaded content = %q, want %q", string(stored), string(content))
}
}
func TestClient_DownloadBlob_NotFound(t *testing.T) {
srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 404, "blob not found")
})
defer srv.Close()
c := NewClient(srv.URL, "tok", "dev-1", "/tmp/vault")
err := c.DownloadBlob("nosuchsha", "/tmp/dest")
if err == nil {
t.Error("expected error for non-existent blob")
}
}
func TestClient_TestAuth(t *testing.T) {
srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/auth/test" {
jsonErr(w, 404, "not found")
return
}
body, _ := io.ReadAll(r.Body)
var req map[string]string
json.Unmarshal(body, &req)
if req["username"] == "user" && req["password"] == "correct" {
jsonOK(w, map[string]string{"status": "ok"})
} else {
jsonErr(w, 401, "invalid credentials")
}
})
defer srv.Close()
c := NewClient(srv.URL, "", "", "")
// Success case.
if err := c.TestAuth(srv.URL, "user", "correct"); err != nil {
t.Errorf("TestAuth (correct): unexpected error: %v", err)
}
// Failure case.
if err := c.TestAuth(srv.URL, "user", "wrong"); err == nil {
t.Error("TestAuth (wrong): expected error")
}
}
func TestClient_GetMe(t *testing.T) {
srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/client/me" {
jsonErr(w, 404, "not found")
return
}
jsonOK(w, map[string]interface{}{
"device_id": "dev-001",
"device_name": "TestClient",
"username": "testuser",
"revoked_at": "",
})
})
defer srv.Close()
c := NewClient(srv.URL, "tok", "dev-1", "/tmp/vault")
info, err := c.GetMe()
if err != nil {
t.Fatalf("GetMe: %v", err)
}
if info.DeviceID != "dev-001" {
t.Errorf("DeviceID = %q, want %q", info.DeviceID, "dev-001")
}
if info.Username != "testuser" {
t.Errorf("Username = %q, want %q", info.Username, "testuser")
}
}
func TestClient_BearerToken_Priority(t *testing.T) {
c := NewClient("http://example.com", "api-key", "dev-1", "/tmp/vault")
// Without DeviceToken, should use APIKey.
if tok := c.bearerToken(); tok != "api-key" {
t.Errorf("bearerToken() = %q, want %q", tok, "api-key")
}
c.DeviceToken = "device-token"
if tok := c.bearerToken(); tok != "device-token" {
t.Errorf("bearerToken() after setting DeviceToken = %q, want %q", tok, "device-token")
}
}
func TestClient_HttpErrors(t *testing.T) {
srv := mockSyncServer(t, func(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 500, "internal error")
})
defer srv.Close()
c := NewClient(srv.URL, "tok", "dev-1", "/tmp/vault")
_, err := c.Push([]Op{{OpID: "o1", EntityType: EntityNode, EntityID: "n1", OpType: OpCreate, PayloadJSON: "{}"}})
if err == nil {
t.Error("expected error for 500 response")
}
if !strings.Contains(err.Error(), "500") {
t.Errorf("error should mention status code: %v", err)
}
}