test: comprehensive sync package unit tests (37 new tests)
- safe_path_test.go: path traversal protection (11 table-driven cases) - blob_test.go: SHA-256 hashing, store/deduplicate/read blobs - sync_test.go: Service CRUD ops, state, push/mark lifecycle - client_test.go: Push/Pull/Blobs/Auth via httptest.Server - sync_e2e_test.go: auto-build server binary on demand
This commit is contained in:
parent
7d81250ebd
commit
ca280a59c0
File diff suppressed because one or more lines are too long
|
|
@ -16,7 +16,7 @@
|
|||
background: #13131f;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/main-CWWXp5bW.js"></script>
|
||||
<script type="module" crossorigin src="/assets/main-hwPUi_6_.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-BBKDbfa7.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBlobDir(t *testing.T) {
|
||||
got := BlobDir("/tmp/vault")
|
||||
want := "/tmp/vault/.verstak/blobs"
|
||||
if got != want {
|
||||
t.Errorf("BlobDir() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlobPath(t *testing.T) {
|
||||
sha := "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
|
||||
got := BlobPath("/tmp/blobs", sha)
|
||||
want := "/tmp/blobs/ab/cd/abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
|
||||
if got != want {
|
||||
t.Errorf("BlobPath() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashBytes(t *testing.T) {
|
||||
got := HashBytes([]byte("hello"))
|
||||
// SHA-256 of "hello"
|
||||
want := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||||
if got != want {
|
||||
t.Errorf("HashBytes() = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
gotEmpty := HashBytes([]byte{})
|
||||
wantEmpty := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
if gotEmpty != wantEmpty {
|
||||
t.Errorf("HashBytes(empty) = %q, want %q", gotEmpty, wantEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Write a test file.
|
||||
path := filepath.Join(dir, "test.txt")
|
||||
if err := os.WriteFile(path, []byte("hello"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := HashFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||||
if got != want {
|
||||
t.Errorf("HashFile() = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// Hash a non-existent file.
|
||||
_, err = HashFile(filepath.Join(dir, "nonexistent"))
|
||||
if err == nil {
|
||||
t.Error("HashFile() expected error for non-existent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreBlob(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
blobsDir := BlobDir(dir)
|
||||
srcDir := t.TempDir()
|
||||
|
||||
// Write source file.
|
||||
srcPath := filepath.Join(srcDir, "data.bin")
|
||||
if err := os.WriteFile(srcPath, []byte("hello world"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Store the blob.
|
||||
sha, err := StoreBlob(blobsDir, srcPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wantSHA := "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
|
||||
if sha != wantSHA {
|
||||
t.Errorf("StoreBlob() sha = %q, want %q", sha, wantSHA)
|
||||
}
|
||||
|
||||
// Verify file exists at the expected path.
|
||||
blobPath := BlobPath(blobsDir, sha)
|
||||
if _, err := os.Stat(blobPath); err != nil {
|
||||
t.Errorf("blob file not created at %s: %v", blobPath, err)
|
||||
}
|
||||
|
||||
// Verify content.
|
||||
data, err := os.ReadFile(blobPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "hello world" {
|
||||
t.Errorf("blob content = %q, want %q", string(data), "hello world")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreBlob_Deduplicate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
blobsDir := BlobDir(dir)
|
||||
srcDir := t.TempDir()
|
||||
|
||||
// Create two files with the same content.
|
||||
src1 := filepath.Join(srcDir, "a.txt")
|
||||
src2 := filepath.Join(srcDir, "b.txt")
|
||||
if err := os.WriteFile(src1, []byte("same content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(src2, []byte("same content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sha1, err := StoreBlob(blobsDir, src1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sha2, err := StoreBlob(blobsDir, src2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if sha1 != sha2 {
|
||||
t.Errorf("StoreBlob() deduplication failed: sha1=%q sha2=%q", sha1, sha2)
|
||||
}
|
||||
|
||||
// Count files in the blob store — should only be 1.
|
||||
var count int
|
||||
filepath.Walk(blobsDir, func(p string, fi os.FileInfo, err error) error {
|
||||
if err == nil && !fi.IsDir() {
|
||||
count++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 blob file, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreBlob_LargeFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
blobsDir := BlobDir(dir)
|
||||
srcDir := t.TempDir()
|
||||
|
||||
// Create a 1MB file.
|
||||
srcPath := filepath.Join(srcDir, "large.bin")
|
||||
data := make([]byte, 1024*1024)
|
||||
for i := range data {
|
||||
data[i] = byte(i % 256)
|
||||
}
|
||||
if err := os.WriteFile(srcPath, data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sha, err := StoreBlob(blobsDir, srcPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sha) != 64 {
|
||||
t.Errorf("expected 64-char SHA-256, got %d chars", len(sha))
|
||||
}
|
||||
|
||||
blobPath := BlobPath(blobsDir, sha)
|
||||
stored, err := os.ReadFile(blobPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stored) != len(data) {
|
||||
t.Errorf("stored blob size = %d, want %d", len(stored), len(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadBlob(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
blobsDir := BlobDir(dir)
|
||||
srcDir := t.TempDir()
|
||||
|
||||
srcPath := filepath.Join(srcDir, "data.txt")
|
||||
if err := os.WriteFile(srcPath, []byte("read test"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sha, err := StoreBlob(blobsDir, srcPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := ReadBlob(blobsDir, sha)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "read test" {
|
||||
t.Errorf("ReadBlob() = %q, want %q", string(data), "read test")
|
||||
}
|
||||
|
||||
// Read non-existent blob.
|
||||
_, err = ReadBlob(blobsDir, strings.Repeat("a", 64))
|
||||
if err == nil {
|
||||
t.Error("ReadBlob() expected error for non-existent blob")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,385 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSafeVaultPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
vaultRoot string
|
||||
relPath string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty path", "/tmp/vault", "", "", true},
|
||||
{"absolute path", "/tmp/vault", "/etc/passwd", "", true},
|
||||
{"simple escape", "/tmp/vault", "../../etc/passwd", "", true},
|
||||
{"escape via prefix", "/tmp/vault", "../outside/foo", "", true},
|
||||
{"clean escape", "/tmp/vault", "a/../../../etc/passwd", "", true},
|
||||
{"simple file", "/tmp/vault", "file.txt", "file.txt", false},
|
||||
{"nested file", "/tmp/vault", "a/b/c/file.txt", "a/b/c/file.txt", false},
|
||||
{"with dots", "/tmp/vault", "a/b/../c/file.txt", "a/c/file.txt", false},
|
||||
{"unicode path", "/tmp/vault", "проекты/файл.txt", "проекты/файл.txt", false},
|
||||
{"root level dir", "/tmp/vault", "notes", "notes", false},
|
||||
{"deeply nested", "/tmp/vault", "clients/acme/projects/website/docs", "clients/acme/projects/website/docs", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := SafeVaultPath(tt.vaultRoot, tt.relPath)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("SafeVaultPath() error = %v, wantErr = %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("SafeVaultPath() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeVaultPaths(t *testing.T) {
|
||||
vaultRoot := "/tmp/vault"
|
||||
|
||||
err := SafeVaultPaths(vaultRoot, "a/b", "c/d", "e/f")
|
||||
if err != nil {
|
||||
t.Errorf("SafeVaultPaths() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
err = SafeVaultPaths(vaultRoot, "a/b", "../../etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("SafeVaultPaths() expected error for escape path, got nil")
|
||||
}
|
||||
|
||||
err = SafeVaultPaths(vaultRoot)
|
||||
if err != nil {
|
||||
t.Errorf("SafeVaultPaths() with no paths: unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -252,7 +252,7 @@ func TestE2ESync(t *testing.T) {
|
|||
|
||||
func findServerBin(t *testing.T) string {
|
||||
t.Helper()
|
||||
// Check common locations.
|
||||
// Check common locations first.
|
||||
candidates := []string{
|
||||
"verstak-server",
|
||||
"../../../verstak-server",
|
||||
|
|
@ -262,7 +262,19 @@ func findServerBin(t *testing.T) string {
|
|||
return c
|
||||
}
|
||||
}
|
||||
return ""
|
||||
// Build it on demand.
|
||||
t.Log("server binary not found — building from source...")
|
||||
cmd := exec.Command("go", "build", "-o", "verstak-server", "../../../cmd/verstak-server")
|
||||
cmd.Dir = t.TempDir()
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Logf("build output: %s", string(output))
|
||||
t.Logf("build error: %v (e2e sync test requires a built server binary)", err)
|
||||
return ""
|
||||
}
|
||||
binPath := filepath.Join(cmd.Dir, "verstak-server")
|
||||
t.Logf("server binary built at %s", binPath)
|
||||
return binPath
|
||||
}
|
||||
|
||||
func pickPort(t *testing.T) int {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,278 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/storage"
|
||||
)
|
||||
|
||||
func newServiceForTest(t *testing.T) (*Service, func()) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := storage.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("storage.Open: %v", err)
|
||||
}
|
||||
svc := NewService(db, "test-device-001")
|
||||
return svc, func() { db.Close() }
|
||||
}
|
||||
|
||||
func TestRecordOp(t *testing.T) {
|
||||
svc, cleanup := newServiceForTest(t)
|
||||
defer cleanup()
|
||||
|
||||
err := svc.RecordOp(EntityNode, "node-001", OpCreate, map[string]interface{}{
|
||||
"title": "Test Node",
|
||||
"type": "case",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RecordOp: %v", err)
|
||||
}
|
||||
|
||||
// Verify the op was recorded.
|
||||
ops, err := svc.GetUnpushedOps()
|
||||
if err != nil {
|
||||
t.Fatalf("GetUnpushedOps: %v", err)
|
||||
}
|
||||
if len(ops) != 1 {
|
||||
t.Fatalf("expected 1 unpushed op, got %d", len(ops))
|
||||
}
|
||||
if ops[0].EntityType != EntityNode {
|
||||
t.Errorf("EntityType = %q, want %q", ops[0].EntityType, EntityNode)
|
||||
}
|
||||
if ops[0].EntityID != "node-001" {
|
||||
t.Errorf("EntityID = %q, want %q", ops[0].EntityID, "node-001")
|
||||
}
|
||||
if ops[0].OpType != OpCreate {
|
||||
t.Errorf("OpType = %q, want %q", ops[0].OpType, OpCreate)
|
||||
}
|
||||
if ops[0].DeviceID != "test-device-001" {
|
||||
t.Errorf("DeviceID = %q, want %q", ops[0].DeviceID, "test-device-001")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordOp_MultipleOps(t *testing.T) {
|
||||
svc, cleanup := newServiceForTest(t)
|
||||
defer cleanup()
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
id := string(rune('A' + i))
|
||||
err := svc.RecordOp(EntityNode, "node-"+id, OpCreate, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordOp %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
ops, err := svc.GetUnpushedOps()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ops) != 5 {
|
||||
t.Errorf("expected 5 unpushed ops, got %d", len(ops))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordOp_WithNilPayload(t *testing.T) {
|
||||
svc, cleanup := newServiceForTest(t)
|
||||
defer cleanup()
|
||||
|
||||
err := svc.RecordOp(EntityAction, "action-001", OpDelete, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordOp with nil payload: %v", err)
|
||||
}
|
||||
|
||||
ops, err := svc.GetUnpushedOps()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ops) != 1 {
|
||||
t.Fatalf("expected 1 op, got %d", len(ops))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkPushed(t *testing.T) {
|
||||
svc, cleanup := newServiceForTest(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := svc.RecordOp(EntityNode, "n1", OpCreate, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.RecordOp(EntityNode, "n2", OpCreate, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ops, err := svc.GetUnpushedOps()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ops) != 2 {
|
||||
t.Fatalf("expected 2 unpushed ops, got %d", len(ops))
|
||||
}
|
||||
|
||||
// Mark first as pushed.
|
||||
if err := svc.MarkPushed([]string{ops[0].OpID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
remaining, err := svc.GetUnpushedOps()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(remaining) != 1 {
|
||||
t.Errorf("expected 1 remaining unpushed op, got %d", len(remaining))
|
||||
}
|
||||
if remaining[0].OpID != ops[1].OpID {
|
||||
t.Errorf("remaining op should be the second one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkPushed_Empty(t *testing.T) {
|
||||
svc, cleanup := newServiceForTest(t)
|
||||
defer cleanup()
|
||||
|
||||
err := svc.MarkPushed([]string{})
|
||||
if err != nil {
|
||||
t.Errorf("MarkPushed with empty list: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkApplied(t *testing.T) {
|
||||
svc, cleanup := newServiceForTest(t)
|
||||
defer cleanup()
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
op := Op{
|
||||
OpID: "remote-op-001",
|
||||
DeviceID: "other-device",
|
||||
EntityType: EntityNode,
|
||||
EntityID: "node-remote",
|
||||
OpType: OpCreate,
|
||||
PayloadJSON: `{"title":"Remote Node"}`,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
if err := svc.RecordRemoteOp(op); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := svc.MarkApplied([]string{"remote-op-001"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetState_SetState(t *testing.T) {
|
||||
svc, cleanup := newServiceForTest(t)
|
||||
defer cleanup()
|
||||
|
||||
// Initial state should be empty.
|
||||
url, key, seq, lastSync, err := svc.GetState()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if url != "" || key != "" || seq != 0 || lastSync != "" {
|
||||
t.Errorf("expected empty initial state, got url=%q key=%q seq=%d lastSync=%q",
|
||||
url, key, seq, lastSync)
|
||||
}
|
||||
|
||||
// Set state.
|
||||
if err := svc.SetState("https://sync.example.com", "test-api-key"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
url, key, seq, lastSync, err = svc.GetState()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if url != "https://sync.example.com" {
|
||||
t.Errorf("url = %q, want %q", url, "https://sync.example.com")
|
||||
}
|
||||
if key != "test-api-key" {
|
||||
t.Errorf("api_key = %q, want %q", key, "test-api-key")
|
||||
}
|
||||
if seq != 0 {
|
||||
t.Errorf("expected seq=0, got %d", seq)
|
||||
}
|
||||
// lastSync could be empty initially.
|
||||
_ = lastSync
|
||||
}
|
||||
|
||||
func TestSetState_Upsert(t *testing.T) {
|
||||
svc, cleanup := newServiceForTest(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := svc.SetState("https://first.example.com", "key1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Upsert with new values.
|
||||
if err := svc.SetState("https://second.example.com", "key2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
url, key, _, _, err := svc.GetState()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if url != "https://second.example.com" {
|
||||
t.Errorf("url after upsert = %q", url)
|
||||
}
|
||||
if key != "key2" {
|
||||
t.Errorf("key after upsert = %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetLastPullSeq(t *testing.T) {
|
||||
svc, cleanup := newServiceForTest(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := svc.SetState("https://example.com", "key"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := svc.SetLastPullSeq(42); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, _, seq, _, err := svc.GetState()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seq != 42 {
|
||||
t.Errorf("last_pull_seq = %d, want 42", seq)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDeviceID(t *testing.T) {
|
||||
svc, cleanup := newServiceForTest(t)
|
||||
defer cleanup()
|
||||
|
||||
if id := svc.GetDeviceID(); id != "test-device-001" {
|
||||
t.Errorf("GetDeviceID() = %q, want %q", id, "test-device-001")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordRemoteOp_Deduplicate(t *testing.T) {
|
||||
svc, cleanup := newServiceForTest(t)
|
||||
defer cleanup()
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
op := Op{
|
||||
OpID: "dup-op",
|
||||
DeviceID: "dev-1",
|
||||
EntityType: EntityNode,
|
||||
EntityID: "node-1",
|
||||
OpType: OpCreate,
|
||||
PayloadJSON: `{}`,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
// Insert the same op twice — second should be IGNORE.
|
||||
if err := svc.RecordRemoteOp(op); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.RecordRemoteOp(op); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue