diff --git a/.gitignore b/.gitignore index b762597..7ac3d2d 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,9 @@ venv/ backend/.venv/ backend/venv/ +# Go test artifacts +go-backend/tests/TEST_DESCRIPTIONS.md + # Misc *.pid *.seed diff --git a/go-backend/go.mod b/go-backend/go.mod index c466436..3fa7b87 100644 --- a/go-backend/go.mod +++ b/go-backend/go.mod @@ -7,6 +7,7 @@ require ( github.com/gin-gonic/gin v1.12.0 github.com/go-git/go-git/v5 v5.17.0 github.com/joho/godotenv v1.5.1 + github.com/stretchr/testify v1.11.1 go.uber.org/zap v1.27.1 ) @@ -20,6 +21,7 @@ require ( github.com/cloudflare/circl v1.6.3 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect @@ -41,6 +43,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect @@ -57,4 +60,5 @@ require ( golang.org/x/text v0.34.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go-backend/tests/handlers_test.go b/go-backend/tests/handlers_test.go new file mode 100644 index 0000000..1871b38 --- /dev/null +++ b/go-backend/tests/handlers_test.go @@ -0,0 +1,170 @@ +package tests + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gittuf/visualizer/go-backend/internal/handlers" + "github.com/gittuf/visualizer/go-backend/internal/models" + "github.com/gittuf/visualizer/go-backend/tests/helpers" + "github.com/stretchr/testify/assert" +) + +func setupRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.Default() + r.POST("/commits", handlers.ListCommits) + r.POST("/metadata", handlers.GetMetadata) + r.POST("/commits-local", handlers.ListCommitsLocal) + r.POST("/metadata-local", handlers.GetMetadataLocal) + return r +} + +func TestListCommits_Success(t *testing.T) { + remotePath, _, cleanupRemote := helpers.SetupTestRepo(t) + defer cleanupRemote() + + r := setupRouter() + jsonValue, _ := json.Marshal(models.CommitsRequest{URL: remotePath}) + req, _ := http.NewRequest("POST", "/commits", bytes.NewBuffer(jsonValue)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + t.Logf("Response: %d %s", w.Code, w.Body.String()) + assert.Equal(t, http.StatusOK, w.Code) + var commits []models.Commit + assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &commits)) + assert.NotEmpty(t, commits) +} + +func TestListCommits_MissingURL(t *testing.T) { + r := setupRouter() + jsonValue, _ := json.Marshal(models.CommitsRequest{}) + req, _ := http.NewRequest("POST", "/commits", bytes.NewBuffer(jsonValue)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + t.Logf("Response: %d %s", w.Code, w.Body.String()) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestListCommits_InvalidURL(t *testing.T) { + r := setupRouter() + jsonValue, _ := json.Marshal(models.CommitsRequest{URL: "invalid-url"}) + req, _ := http.NewRequest("POST", "/commits", bytes.NewBuffer(jsonValue)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + t.Logf("Response: %d %s", w.Code, w.Body.String()) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestGetMetadata_Success(t *testing.T) { + remotePath, commitHash, cleanupRemote := helpers.SetupTestRepo(t) + defer cleanupRemote() + + r := setupRouter() + jsonValue, _ := json.Marshal(models.MetadataRequest{URL: remotePath, Commit: commitHash, File: "root.json"}) + req, _ := http.NewRequest("POST", "/metadata", bytes.NewBuffer(jsonValue)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + t.Logf("Response: %d %s", w.Code, w.Body.String()) + assert.Equal(t, http.StatusOK, w.Code) + var metadata map[string]interface{} + assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &metadata)) + assert.Equal(t, "root", metadata["type"]) +} + +func TestGetMetadata_MissingURL(t *testing.T) { + r := setupRouter() + jsonValue, _ := json.Marshal(models.MetadataRequest{}) + req, _ := http.NewRequest("POST", "/metadata", bytes.NewBuffer(jsonValue)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + t.Logf("Response: %d %s", w.Code, w.Body.String()) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestListCommitsLocal_Success(t *testing.T) { + repoPath, _, cleanup := helpers.SetupTestRepo(t) + defer cleanup() + + r := setupRouter() + jsonValue, _ := json.Marshal(models.CommitsLocalRequest{Path: repoPath}) + req, _ := http.NewRequest("POST", "/commits-local", bytes.NewBuffer(jsonValue)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + t.Logf("Response: %d %s", w.Code, w.Body.String()) + assert.Equal(t, http.StatusOK, w.Code) + var commits []models.Commit + assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &commits)) + assert.NotEmpty(t, commits) +} + +func TestListCommitsLocal_MissingPath(t *testing.T) { + r := setupRouter() + jsonValue, _ := json.Marshal(models.CommitsLocalRequest{}) + req, _ := http.NewRequest("POST", "/commits-local", bytes.NewBuffer(jsonValue)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + t.Logf("Response: %d %s", w.Code, w.Body.String()) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestListCommitsLocal_InvalidPath(t *testing.T) { + r := setupRouter() + jsonValue, _ := json.Marshal(models.CommitsLocalRequest{Path: "/invalid/path"}) + req, _ := http.NewRequest("POST", "/commits-local", bytes.NewBuffer(jsonValue)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + t.Logf("Response: %d %s", w.Code, w.Body.String()) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestGetMetadataLocal_Success(t *testing.T) { + repoPath, commitHash, cleanup := helpers.SetupTestRepo(t) + defer cleanup() + + r := setupRouter() + jsonValue, _ := json.Marshal(models.MetadataLocalRequest{Path: repoPath, Commit: commitHash, File: "root.json"}) + req, _ := http.NewRequest("POST", "/metadata-local", bytes.NewBuffer(jsonValue)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + t.Logf("Response: %d %s", w.Code, w.Body.String()) + assert.Equal(t, http.StatusOK, w.Code) + var metadata map[string]interface{} + assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &metadata)) + assert.Equal(t, "root", metadata["type"]) +} + +func TestGetMetadataLocal_MissingFields(t *testing.T) { + r := setupRouter() + jsonValue, _ := json.Marshal(models.MetadataLocalRequest{}) + req, _ := http.NewRequest("POST", "/metadata-local", bytes.NewBuffer(jsonValue)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + t.Logf("Response: %d %s", w.Code, w.Body.String()) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestGetMetadataLocal_InvalidPath(t *testing.T) { + r := setupRouter() + jsonValue, _ := json.Marshal(models.MetadataLocalRequest{Path: "/invalid/path", Commit: "HEAD", File: "root.json"}) + req, _ := http.NewRequest("POST", "/metadata-local", bytes.NewBuffer(jsonValue)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + t.Logf("Response: %d %s", w.Code, w.Body.String()) + assert.Equal(t, http.StatusBadRequest, w.Code) +} diff --git a/go-backend/tests/helpers/helpers.go b/go-backend/tests/helpers/helpers.go new file mode 100644 index 0000000..12d91e8 --- /dev/null +++ b/go-backend/tests/helpers/helpers.go @@ -0,0 +1,92 @@ +package helpers + +import ( + "encoding/base64" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// SetupTestRepo creates a temporary git repository with two commits: an initial commit +// and a second commit containing a gittuf metadata envelope at metadata/root.json, +// with refs/gittuf/policy pointing at that second commit. +func SetupTestRepo(t *testing.T) (string, string, func()) { + t.Helper() + + tempDir, err := os.MkdirTemp("", "gittuf-viz-test-repo-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + repo, err := git.PlainInit(tempDir, false) + if err != nil { + os.RemoveAll(tempDir) + t.Fatalf("Failed to init git repo: %v", err) + } + + w, err := repo.Worktree() + if err != nil { + os.RemoveAll(tempDir) + t.Fatalf("Failed to get worktree: %v", err) + } + + dummyFile := filepath.Join(tempDir, "README.md") + if err := os.WriteFile(dummyFile, []byte("# Test Repo"), 0600); err != nil { + os.RemoveAll(tempDir) + t.Fatalf("Failed to write dummy file: %v", err) + } + if _, err := w.Add("README.md"); err != nil { + os.RemoveAll(tempDir) + t.Fatalf("Failed to add file: %v", err) + } + if _, err := w.Commit("Initial commit", &git.CommitOptions{ + Author: &object.Signature{Name: "Test User", Email: "test@example.com", When: time.Now()}, + }); err != nil { + os.RemoveAll(tempDir) + t.Fatalf("Failed to commit: %v", err) + } + + rootJSON := `{"type":"root", "expires":"2030-01-01T00:00:00Z"}` + rootB64 := base64.StdEncoding.EncodeToString([]byte(rootJSON)) + envelope := fmt.Sprintf(`{"payload": "%s", "signatures": []}`, rootB64) + + metadataDir := filepath.Join(tempDir, "metadata") + if err := os.MkdirAll(metadataDir, 0750); err != nil { + os.RemoveAll(tempDir) + t.Fatalf("Failed to create metadata dir: %v", err) + } + + policyFile := filepath.Join(metadataDir, "root.json") + if err := os.WriteFile(policyFile, []byte(envelope), 0600); err != nil { + os.RemoveAll(tempDir) + t.Fatalf("Failed to write policy file: %v", err) + } + if _, err := w.Add("metadata/root.json"); err != nil { + os.RemoveAll(tempDir) + t.Fatalf("Failed to add policy file: %v", err) + } + + commitHash, err := w.Commit("Add root.json", &git.CommitOptions{ + Author: &object.Signature{Name: "Gittuf Admin", Email: "admin@gittuf.com", When: time.Now()}, + }) + if err != nil { + os.RemoveAll(tempDir) + t.Fatalf("Failed to commit policy: %v", err) + } + + ref := plumbing.NewHashReference("refs/gittuf/policy", commitHash) + if err := repo.Storer.SetReference(ref); err != nil { + os.RemoveAll(tempDir) + t.Fatalf("Failed to set policy ref: %v", err) + } + + t.Logf("Test repo: path=%s policy_commit=%s", tempDir, commitHash) + + return tempDir, commitHash.String(), func() { os.RemoveAll(tempDir) } +} diff --git a/go-backend/tests/main_test.go b/go-backend/tests/main_test.go new file mode 100644 index 0000000..c396f0e --- /dev/null +++ b/go-backend/tests/main_test.go @@ -0,0 +1,16 @@ +package tests + +import ( + "os" + "testing" + + "github.com/gittuf/visualizer/go-backend/internal/logger" +) + +// TestMain sets up the test environment and initializes the logger. +func TestMain(m *testing.M) { + logger.Initialize() + code := m.Run() + logger.Sync() + os.Exit(code) +} diff --git a/go-backend/tests/services_test.go b/go-backend/tests/services_test.go new file mode 100644 index 0000000..7860965 --- /dev/null +++ b/go-backend/tests/services_test.go @@ -0,0 +1,100 @@ +package tests + +import ( + "os" + "testing" + + "github.com/gittuf/visualizer/go-backend/internal/services" + "github.com/gittuf/visualizer/go-backend/tests/helpers" + "github.com/stretchr/testify/assert" +) + +func TestCloneAndFetchRepo_Success(t *testing.T) { + remotePath, _, cleanupRemote := helpers.SetupTestRepo(t) + defer cleanupRemote() + + localPath, cleanupLocal, err := services.CloneAndFetchRepo(remotePath) + assert.NoError(t, err) + defer cleanupLocal() + + t.Logf("Cloned to: %s", localPath) + _, statErr := os.Stat(localPath) + assert.NoError(t, statErr) +} + +func TestCloneAndFetchRepo_InvalidURL(t *testing.T) { + _, _, err := services.CloneAndFetchRepo("invalid-url") + assert.Error(t, err) + t.Logf("Error: %v", err) +} + +func TestGetPolicyCommits_Success(t *testing.T) { + remotePath, commitHash, cleanupRemote := helpers.SetupTestRepo(t) + defer cleanupRemote() + + localPath, cleanupLocal, err := services.CloneAndFetchRepo(remotePath) + assert.NoError(t, err) + defer cleanupLocal() + + commits, err := services.GetPolicyCommits(localPath) + assert.NoError(t, err) + assert.NotEmpty(t, commits) + assert.Equal(t, commitHash, commits[0].Hash) + t.Logf("Retrieved %d policy commit(s), latest: %s", len(commits), commits[0].Hash) +} + +func TestGetPolicyCommits_InvalidPath(t *testing.T) { + _, err := services.GetPolicyCommits("invalid-path") + assert.Error(t, err) + t.Logf("Error: %v", err) +} + +func TestGetLocalCommits_Success(t *testing.T) { + repoPath, _, cleanup := helpers.SetupTestRepo(t) + defer cleanup() + + commits, err := services.GetLocalCommits(repoPath) + assert.NoError(t, err) + assert.NotEmpty(t, commits) + t.Logf("Retrieved %d local commit(s)", len(commits)) +} + +func TestGetLocalCommits_InvalidPath(t *testing.T) { + _, err := services.GetLocalCommits("invalid-path") + assert.Error(t, err) + t.Logf("Error: %v", err) +} + +func TestDecodeMetadataBlob_Success(t *testing.T) { + remotePath, commitHash, cleanupRemote := helpers.SetupTestRepo(t) + defer cleanupRemote() + + localPath, cleanupLocal, err := services.CloneAndFetchRepo(remotePath) + assert.NoError(t, err) + defer cleanupLocal() + + metadata, err := services.DecodeMetadataBlob(localPath, commitHash, "root.json") + assert.NoError(t, err) + assert.NotNil(t, metadata) + assert.Equal(t, "root", metadata["type"]) + t.Logf("Decoded metadata type: %v", metadata["type"]) +} + +func TestDecodeMetadataBlob_InvalidPath(t *testing.T) { + _, err := services.DecodeMetadataBlob("invalid-path", "HEAD", "root.json") + assert.Error(t, err) + t.Logf("Error: %v", err) +} + +func TestDecodeMetadataBlob_InvalidFile(t *testing.T) { + remotePath, commitHash, cleanupRemote := helpers.SetupTestRepo(t) + defer cleanupRemote() + + localPath, cleanupLocal, err := services.CloneAndFetchRepo(remotePath) + assert.NoError(t, err) + defer cleanupLocal() + + _, err = services.DecodeMetadataBlob(localPath, commitHash, "nonexistent.json") + assert.Error(t, err) + t.Logf("Error: %v", err) +} diff --git a/go-backend/tests/validation_test.go b/go-backend/tests/validation_test.go new file mode 100644 index 0000000..be86872 --- /dev/null +++ b/go-backend/tests/validation_test.go @@ -0,0 +1,39 @@ +package tests + +import ( + "os" + "testing" + + "github.com/gittuf/visualizer/go-backend/internal/validation" + "github.com/gittuf/visualizer/go-backend/tests/helpers" + "github.com/stretchr/testify/assert" +) + +func TestPathExists(t *testing.T) { + repoPath, _, cleanup := helpers.SetupTestRepo(t) + defer cleanup() + + assert.True(t, validation.PathExists(repoPath)) + assert.False(t, validation.PathExists("/nonexistent/path")) +} + +func TestIsValidGitRepo(t *testing.T) { + repoPath, _, cleanup := helpers.SetupTestRepo(t) + defer cleanup() + + assert.True(t, validation.IsValidGitRepo(repoPath)) + + plainDir, err := os.MkdirTemp("", "plain-dir-*") + assert.NoError(t, err) + defer os.RemoveAll(plainDir) + assert.False(t, validation.IsValidGitRepo(plainDir)) + + assert.False(t, validation.IsValidGitRepo("/nonexistent/path")) +} + +func TestGetAbsolutePath(t *testing.T) { + absPath, err := validation.GetAbsolutePath(".") + assert.NoError(t, err) + assert.True(t, validation.PathExists(absPath)) + t.Logf("Resolved: %s", absPath) +}