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;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</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">
|
<link rel="stylesheet" crossorigin href="/assets/main-BBKDbfa7.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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 {
|
func findServerBin(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
// Check common locations.
|
// Check common locations first.
|
||||||
candidates := []string{
|
candidates := []string{
|
||||||
"verstak-server",
|
"verstak-server",
|
||||||
"../../../verstak-server",
|
"../../../verstak-server",
|
||||||
|
|
@ -262,7 +262,19 @@ func findServerBin(t *testing.T) string {
|
||||||
return c
|
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 {
|
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