diff --git a/apps/backend/internal/services/graph/service_test.go b/apps/backend/internal/services/graph/service_test.go new file mode 100644 index 0000000..33bda9c --- /dev/null +++ b/apps/backend/internal/services/graph/service_test.go @@ -0,0 +1,50 @@ +package graph + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/goddhi/ucan-visualizer/test/fixtures" +) + +func TestGenerateDelegationGraph(t *testing.T) { + svc := NewService() + + t.Run("Generates nodes and edges for chain", func(t *testing.T) { + tokenBytes, err := fixtures.GenerateComplexChain() + require.NoError(t, err) + + graph, err := svc.GenerateDelegationGraph(tokenBytes) + require.NoError(t, err) + + assert.GreaterOrEqual(t, len(graph.Nodes), 2) + + hasRoot := false + hasLeaf := false + for _, node := range graph.Nodes { + if node.Type == "root" { hasRoot = true } + if node.Type == "leaf" { hasLeaf = true } + } + assert.True(t, hasRoot, "Graph must have a root node") + assert.True(t, hasLeaf, "Graph must have a leaf node") + + assert.NotEmpty(t, graph.Edges) + assert.Equal(t, "delegation", graph.Edges[0].Type) + }) +} + +func TestGenerateInvocationGraph(t *testing.T) { + svc := NewService() + + t.Run("Identifies delegation as NOT invocation", func(t *testing.T) { + tokenBytes, err := fixtures.GenerateValidUCAN() + require.NoError(t, err) + + graph, err := svc.GenerateInvocationGraph(tokenBytes) + require.NoError(t, err) + + assert.False(t, graph.IsInvocation) + assert.NotEmpty(t, graph.Nodes) + }) +} \ No newline at end of file diff --git a/apps/backend/internal/services/parser/service_test.go b/apps/backend/internal/services/parser/service_test.go new file mode 100644 index 0000000..401d8ef --- /dev/null +++ b/apps/backend/internal/services/parser/service_test.go @@ -0,0 +1,52 @@ +package parser + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/goddhi/ucan-visualizer/test/fixtures" +) + +func TestParseDelegation(t *testing.T) { + svc := NewService() + + t.Run("Successfully parses valid CAR token", func(t *testing.T) { + tokenBytes, err := fixtures.GenerateValidUCAN() + require.NoError(t, err) + + result, err := svc.ParseDelegation(tokenBytes) + + require.NoError(t, err) + assert.NotEmpty(t, result.CID) + assert.NotEmpty(t, result.Issuer) + assert.NotEmpty(t, result.Audience) + + assert.Greater(t, len(result.Capabilities), 0) + assert.Equal(t, "store/add", result.Capabilities[0].Can) + }) + + t.Run("Fails on garbage input", func(t *testing.T) { + garbageBytes := []byte("this is not a real UCAN") + + result, err := svc.ParseDelegation(garbageBytes) + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "failed to decode") + }) +} + +func TestParseDelegationChain(t *testing.T) { + svc := NewService() + + t.Run("Parses multi-hop chain", func(t *testing.T) { + chainBytes, err := fixtures.GenerateComplexChain() + require.NoError(t, err) + + chain, err := svc.ParseDelegationChain(chainBytes) + + require.NoError(t, err) + assert.NotEmpty(t, chain) + }) +} \ No newline at end of file diff --git a/apps/backend/internal/services/validator/service_test.go b/apps/backend/internal/services/validator/service_test.go new file mode 100644 index 0000000..6392cee --- /dev/null +++ b/apps/backend/internal/services/validator/service_test.go @@ -0,0 +1,61 @@ +package validator + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/goddhi/ucan-visualizer/test/fixtures" +) + +func TestValidateChain(t *testing.T) { + svc := NewService() + + t.Run("Validates chain", func(t *testing.T) { + tokenBytes, err := fixtures.GenerateComplexChain() + require.NoError(t, err) + + result, err := svc.ValidateChain(tokenBytes) + require.NoError(t, err) + + assert.True(t, result.Valid) + assert.Equal(t, 0, result.Summary.InvalidLinks) + assert.Nil(t, result.RootCause) + + assert.GreaterOrEqual(t, len(result.Chain), 1) + }) + + t.Run("Detects expired tokens", func(t *testing.T) { + tokenBytes, err := fixtures.GenerateExpiredUCAN() + require.NoError(t, err) + + result, err := svc.ValidateChain(tokenBytes) + require.NoError(t, err) + + assert.False(t, result.Valid) + assert.NotNil(t, result.RootCause) + assert.Equal(t, "expired", result.RootCause.Type) + assert.Contains(t, result.RootCause.Message, "UCAN expired") + }) +} + +func TestResourceMatching(t *testing.T) { + svc := NewService() + + tests := []struct { + parent string + child string + expected bool + }{ + {"storage:*", "storage:alice/photos", true}, + {"storage:alice/*", "storage:alice/photos", true}, + {"storage:alice/*", "storage:bob/photos", false}, + {"*", "anything", true}, + } + + for _, tt := range tests { + t.Run(tt.parent+" -> "+tt.child, func(t *testing.T) { + assert.Equal(t, tt.expected, svc.resourceMatches(tt.parent, tt.child)) + }) + } +} \ No newline at end of file diff --git a/apps/backend/pkg/utils/utils_test.go b/apps/backend/pkg/utils/utils_test.go new file mode 100644 index 0000000..3336fea --- /dev/null +++ b/apps/backend/pkg/utils/utils_test.go @@ -0,0 +1,70 @@ +package utils + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestShortenDID(t *testing.T) { + tests := []struct { + input string + expected string + }{ + { + "did:key:z6MkhaXgBZDnaWnM", + "did:key:z6MkhaXgBZDnaWnM", // Short enough, + }, + { + "did:key:z6MkhaXgBZDnaWnMz6MkhaXgBZDnaWnMz6MkhaXgBZDnaWnM", + "did:key:z6MkhaXg...gBZDnaWnM", // Should truncate + }, + } + + for _, tt := range tests { + assert.Equal(t, tt.expected, ShortenDID(tt.input)) + } +} + +func TestNormalizeToken(t *testing.T) { + raw := []byte("raw-token-data") + encoded := base64.StdEncoding.EncodeToString(raw) + + t.Run("Decodes Base64 explicit", func(t *testing.T) { + res, err := NormalizeToken(encoded, "base64") + assert.NoError(t, err) + assert.Equal(t, raw, res) + }) + + t.Run("Auto-detects Base64 in Raw", func(t *testing.T) { + res, err := NormalizeToken(encoded, "raw") + assert.NoError(t, err) + assert.Equal(t, raw, res) + }) + + t.Run("Handles plain raw string", func(t *testing.T) { + // If it's not base64, it should return as is + res, err := NormalizeToken("not-base-64", "raw") + assert.NoError(t, err) + assert.Equal(t, []byte("not-base-64"), res) + }) +} + +func TestValidateTokenFormat(t *testing.T) { + t.Run("Rejects empty", func(t *testing.T) { + err := ValidateTokenFormat("") + assert.Error(t, err) + }) + + t.Run("Rejects too short", func(t *testing.T) { + err := ValidateTokenFormat("abc") + assert.Error(t, err) + }) + + t.Run("Accepts valid base64", func(t *testing.T) { + valid := base64.StdEncoding.EncodeToString([]byte("valid-token-content")) + err := ValidateTokenFormat(valid) + assert.NoError(t, err) + }) +} \ No newline at end of file diff --git a/apps/backend/test/integration/api_test.go b/apps/backend/test/integration/api_test.go index dcb4ab6..7c0eb95 100644 --- a/apps/backend/test/integration/api_test.go +++ b/apps/backend/test/integration/api_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/base64" "encoding/json" + "mime/multipart" "net/http" "net/http/httptest" "testing" @@ -15,232 +16,190 @@ import ( "github.com/goddhi/ucan-visualizer/test/fixtures" ) -func TestParseEndpoint(t *testing.T) { - // Setup test server +// --- Helper Functions --- + +func createMultipartRequest(t *testing.T, url string, filename string, fileContent []byte) *http.Request { + body := new(bytes.Buffer) + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("file", filename) + require.NoError(t, err) + _, err = part.Write(fileContent) + require.NoError(t, err) + err = writer.Close() + require.NoError(t, err) + + req, err := http.NewRequest("POST", url, body) + require.NoError(t, err) + req.Header.Set("Content-Type", writer.FormDataContentType()) + return req +} + +func setupServer() *httptest.Server { handler := api.SetupRouter() - server := httptest.NewServer(handler) + return httptest.NewServer(handler) +} + + +func TestHealthCheck(t *testing.T) { + server := setupServer() defer server.Close() - t.Run("Parse valid delegation", func(t *testing.T) { - // Generate a valid UCAN - tokenBytes, err := fixtures.GenerateValidUCAN() - require.NoError(t, err) + resp, err := http.Get(server.URL + "/health") + require.NoError(t, err) + defer resp.Body.Close() - // Encode as base64 for JSON transport - tokenStr := base64.StdEncoding.EncodeToString(tokenBytes) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + assert.Equal(t, "healthy", result["status"]) +} - // Prepare request - payload := models.ParseRequest{ - Token: tokenStr, - } - body, _ := json.Marshal(payload) +func TestParseEndpoints(t *testing.T) { + server := setupServer() + defer server.Close() - // Make request - resp, err := http.Post( - server.URL+"/api/parse/delegation", - "application/json", - bytes.NewBuffer(body), - ) - require.NoError(t, err) - defer resp.Body.Close() + validToken, _ := fixtures.GenerateValidUCAN() + complexChain, _ := fixtures.GenerateComplexChain() + + validTokenStr := base64.StdEncoding.EncodeToString(validToken) + complexChainStr := base64.StdEncoding.EncodeToString(complexChain) - // Check response + t.Run("POST /parse/delegation (JSON)", func(t *testing.T) { + payload := models.ParseRequest{Token: validTokenStr} + body, _ := json.Marshal(payload) + resp, err := http.Post(server.URL+"/api/parse/delegation", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) + }) - // Parse response - var result models.DelegationResponse - err = json.NewDecoder(resp.Body).Decode(&result) + t.Run("POST /parse/delegation/file (File)", func(t *testing.T) { + req := createMultipartRequest(t, server.URL+"/api/parse/delegation/file", "token.ucan", validToken) + resp, err := http.DefaultClient.Do(req) require.NoError(t, err) - - // Verify parsed data - assert.NotEmpty(t, result.Issuer) - assert.NotEmpty(t, result.Audience) - assert.NotEmpty(t, result.CID) - assert.Greater(t, len(result.Capabilities), 0) - - t.Logf("✅ Parsed delegation: %s", result.CID) - t.Logf(" Issuer: %s", result.Issuer) - t.Logf(" Audience: %s", result.Audience) - t.Logf(" Capabilities: %d", len(result.Capabilities)) + assert.Equal(t, http.StatusOK, resp.StatusCode) }) - t.Run("Parse with invalid token", func(t *testing.T) { - payload := models.ParseRequest{ - Token: "invalid-token", - } + t.Run("POST /parse/chain (JSON)", func(t *testing.T) { + payload := models.ParseRequest{Token: complexChainStr} body, _ := json.Marshal(payload) - - resp, err := http.Post( - server.URL+"/api/parse/delegation", - "application/json", - bytes.NewBuffer(body), - ) + resp, err := http.Post(server.URL+"/api/parse/chain", "application/json", bytes.NewBuffer(body)) require.NoError(t, err) - defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) - assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode) + t.Run("POST /parse/chain/file (File)", func(t *testing.T) { + req := createMultipartRequest(t, server.URL+"/api/parse/chain/file", "chain.ucan", complexChain) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) }) - t.Run("Parse with empty token", func(t *testing.T) { - payload := models.ParseRequest{ - Token: "", - } + t.Run("POST /parse/invocation (JSON)", func(t *testing.T) { + payload := models.ParseRequest{Token: validTokenStr} body, _ := json.Marshal(payload) - - resp, err := http.Post( - server.URL+"/api/parse/delegation", - "application/json", - bytes.NewBuffer(body), - ) + resp, err := http.Post(server.URL+"/api/parse/invocation", "application/json", bytes.NewBuffer(body)) require.NoError(t, err) - defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + t.Run("POST /parse/invocation/file (File)", func(t *testing.T) { + req := createMultipartRequest(t, server.URL+"/api/parse/invocation/file", "invoke.ucan", validToken) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) }) } -func TestValidateEndpoint(t *testing.T) { - handler := api.SetupRouter() - server := httptest.NewServer(handler) +func TestValidateEndpoints(t *testing.T) { + server := setupServer() defer server.Close() - t.Run("Validate valid delegation", func(t *testing.T) { - // Generate a valid UCAN - tokenBytes, err := fixtures.GenerateValidUCAN() - require.NoError(t, err) - - tokenStr := base64.StdEncoding.EncodeToString(tokenBytes) + complexChain, _ := fixtures.GenerateComplexChain() + complexChainStr := base64.StdEncoding.EncodeToString(complexChain) - payload := models.ValidateRequest{ - Token: tokenStr, - } + t.Run("POST /validate/chain (JSON)", func(t *testing.T) { + payload := models.ValidateRequest{Token: complexChainStr} body, _ := json.Marshal(payload) - - resp, err := http.Post( - server.URL+"/api/validate/chain", - "application/json", - bytes.NewBuffer(body), - ) + resp, err := http.Post(server.URL+"/api/validate/chain", "application/json", bytes.NewBuffer(body)) require.NoError(t, err) - defer resp.Body.Close() - + + var result models.ValidationResult + json.NewDecoder(resp.Body).Decode(&result) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.True(t, result.Valid) + }) - var result models.ValidationResult - err = json.NewDecoder(resp.Body).Decode(&result) + t.Run("POST /validate/chain/file (File)", func(t *testing.T) { + req := createMultipartRequest(t, server.URL+"/api/validate/chain/file", "chain.ucan", complexChain) + resp, err := http.DefaultClient.Do(req) require.NoError(t, err) + + var result models.ValidationResult + json.NewDecoder(resp.Body).Decode(&result) - // Check validation result - t.Logf("✅ Validation result: Valid=%v", result.Valid) - t.Logf(" Total links: %d", result.Summary.TotalLinks) - t.Logf(" Valid links: %d", result.Summary.ValidLinks) - t.Logf(" Invalid links: %d", result.Summary.InvalidLinks) - - if len(result.Chain) > 0 { - t.Logf(" First link issues: %d", len(result.Chain[0].Issues)) - for _, issue := range result.Chain[0].Issues { - t.Logf(" - [%s] %s", issue.Severity, issue.Message) - } - } + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.True(t, result.Valid) }) - t.Run("Validate expired delegation", func(t *testing.T) { - tokenBytes, err := fixtures.GenerateExpiredUCAN() - require.NoError(t, err) - - tokenStr := base64.StdEncoding.EncodeToString(tokenBytes) + t.Run("Validate Expired Token", func(t *testing.T) { + expiredToken, _ := fixtures.GenerateExpiredUCAN() + expiredTokenStr := base64.StdEncoding.EncodeToString(expiredToken) - payload := models.ValidateRequest{ - Token: tokenStr, - } + payload := models.ValidateRequest{Token: expiredTokenStr} body, _ := json.Marshal(payload) - - resp, err := http.Post( - server.URL+"/api/validate/chain", - "application/json", - bytes.NewBuffer(body), - ) + resp, err := http.Post(server.URL+"/api/validate/chain", "application/json", bytes.NewBuffer(body)) require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) var result models.ValidationResult - err = json.NewDecoder(resp.Body).Decode(&result) - require.NoError(t, err) + json.NewDecoder(resp.Body).Decode(&result) - // Should be invalid due to expiration + assert.Equal(t, http.StatusOK, resp.StatusCode) assert.False(t, result.Valid) - assert.NotNil(t, result.RootCause) - assert.Equal(t, "expired", result.RootCause.Type) - - t.Logf("✅ Correctly detected expired UCAN") }) } -func TestGraphEndpoint(t *testing.T) { - handler := api.SetupRouter() - server := httptest.NewServer(handler) +func TestGraphEndpoints(t *testing.T) { + server := setupServer() defer server.Close() - t.Run("Generate graph from valid delegation", func(t *testing.T) { - tokenBytes, err := fixtures.GenerateValidUCAN() - require.NoError(t, err) - - tokenStr := base64.StdEncoding.EncodeToString(tokenBytes) + complexChain, _ := fixtures.GenerateComplexChain() + complexChainStr := base64.StdEncoding.EncodeToString(complexChain) - payload := models.GraphRequest{ - Token: tokenStr, - } + t.Run("POST /graph/delegation (JSON)", func(t *testing.T) { + payload := models.GraphRequest{Token: complexChainStr} body, _ := json.Marshal(payload) - - resp, err := http.Post( - server.URL+"/api/graph/delegation", - "application/json", - bytes.NewBuffer(body), - ) + resp, err := http.Post(server.URL+"/api/graph/delegation", "application/json", bytes.NewBuffer(body)) require.NoError(t, err) - defer resp.Body.Close() + + var result models.GraphResponse + json.NewDecoder(resp.Body).Decode(&result) assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.NotEmpty(t, result.Nodes) + assert.NotEmpty(t, result.Edges) + }) - var result models.GraphResponse - err = json.NewDecoder(resp.Body).Decode(&result) + t.Run("POST /graph/delegation/file (File)", func(t *testing.T) { + req := createMultipartRequest(t, server.URL+"/api/graph/delegation/file", "graph.ucan", complexChain) + resp, err := http.DefaultClient.Do(req) require.NoError(t, err) - - // Check graph structure - assert.GreaterOrEqual(t, len(result.Nodes), 2, "Should have at least 2 nodes (issuer and audience)") - assert.Greater(t, len(result.Edges), 0, "Should have at least 1 edge") - - t.Logf("✅ Generated graph:") - t.Logf(" Nodes: %d", len(result.Nodes)) - t.Logf(" Edges: %d", len(result.Edges)) - - for i, node := range result.Nodes { - t.Logf(" Node %d: %s (%s)", i+1, node.Label, node.Type) - } - - for i, edge := range result.Edges { - t.Logf(" Edge %d: %s -> %s (%s)", i+1, edge.Source, edge.Target, edge.Label) - } + assert.Equal(t, http.StatusOK, resp.StatusCode) }) -} - -func TestHealthEndpoint(t *testing.T) { - handler := api.SetupRouter() - server := httptest.NewServer(handler) - defer server.Close() - - resp, err := http.Get(server.URL + "/health") - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - var result map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&result) - require.NoError(t, err) + t.Run("POST /graph/invocation (JSON)", func(t *testing.T) { + payload := models.GraphRequest{Token: complexChainStr} + body, _ := json.Marshal(payload) + resp, err := http.Post(server.URL+"/api/graph/invocation", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) - assert.Equal(t, "healthy", result["status"]) - t.Logf("✅ Health check passed") + t.Run("POST /graph/invocation/file (File)", func(t *testing.T) { + req := createMultipartRequest(t, server.URL+"/api/graph/invocation/file", "inv_graph.ucan", complexChain) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) } \ No newline at end of file