386 lines
10 KiB
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)
|
|
}
|
|
}
|