diff --git a/e2e/cluster/rqlite_failover_test.go b/e2e/cluster/rqlite_failover_test.go new file mode 100644 index 0000000..e2fe86b --- /dev/null +++ b/e2e/cluster/rqlite_failover_test.go @@ -0,0 +1,177 @@ +//go:build e2e + +package cluster + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/DeBrosOfficial/network/e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRQLite_ReadConsistencyLevels tests that different consistency levels work. +func TestRQLite_ReadConsistencyLevels(t *testing.T) { + e2e.SkipIfMissingGateway(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + gatewayURL := e2e.GetGatewayURL() + table := e2e.GenerateTableName() + + defer func() { + dropReq := &e2e.HTTPRequest{ + Method: http.MethodPost, + URL: gatewayURL + "/v1/rqlite/drop-table", + Body: map[string]interface{}{"table": table}, + } + dropReq.Do(context.Background()) + }() + + // Create table + createReq := &e2e.HTTPRequest{ + Method: http.MethodPost, + URL: gatewayURL + "/v1/rqlite/create-table", + Body: map[string]interface{}{ + "schema": fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (id INTEGER PRIMARY KEY AUTOINCREMENT, val TEXT)", table), + }, + } + _, status, err := createReq.Do(ctx) + require.NoError(t, err) + require.True(t, status == http.StatusOK || status == http.StatusCreated, "create table got %d", status) + + // Insert data + insertReq := &e2e.HTTPRequest{ + Method: http.MethodPost, + URL: gatewayURL + "/v1/rqlite/transaction", + Body: map[string]interface{}{ + "statements": []string{ + fmt.Sprintf("INSERT INTO %s(val) VALUES ('consistency-test')", table), + }, + }, + } + _, status, err = insertReq.Do(ctx) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status) + + t.Run("Default consistency read", func(t *testing.T) { + queryReq := &e2e.HTTPRequest{ + Method: http.MethodPost, + URL: gatewayURL + "/v1/rqlite/query", + Body: map[string]interface{}{ + "sql": fmt.Sprintf("SELECT * FROM %s", table), + }, + } + body, status, err := queryReq.Do(ctx) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, status) + t.Logf("Default read: %s", string(body)) + }) + + t.Run("Strong consistency read", func(t *testing.T) { + queryReq := &e2e.HTTPRequest{ + Method: http.MethodPost, + URL: gatewayURL + "/v1/rqlite/query?level=strong", + Body: map[string]interface{}{ + "sql": fmt.Sprintf("SELECT * FROM %s", table), + }, + } + body, status, err := queryReq.Do(ctx) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, status) + t.Logf("Strong read: %s", string(body)) + }) + + t.Run("Weak consistency read", func(t *testing.T) { + queryReq := &e2e.HTTPRequest{ + Method: http.MethodPost, + URL: gatewayURL + "/v1/rqlite/query?level=weak", + Body: map[string]interface{}{ + "sql": fmt.Sprintf("SELECT * FROM %s", table), + }, + } + body, status, err := queryReq.Do(ctx) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, status) + t.Logf("Weak read: %s", string(body)) + }) +} + +// TestRQLite_WriteAfterMultipleReads verifies write-read cycles stay consistent. +func TestRQLite_WriteAfterMultipleReads(t *testing.T) { + e2e.SkipIfMissingGateway(t) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + gatewayURL := e2e.GetGatewayURL() + table := e2e.GenerateTableName() + + defer func() { + dropReq := &e2e.HTTPRequest{ + Method: http.MethodPost, + URL: gatewayURL + "/v1/rqlite/drop-table", + Body: map[string]interface{}{"table": table}, + } + dropReq.Do(context.Background()) + }() + + createReq := &e2e.HTTPRequest{ + Method: http.MethodPost, + URL: gatewayURL + "/v1/rqlite/create-table", + Body: map[string]interface{}{ + "schema": fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (id INTEGER PRIMARY KEY AUTOINCREMENT, counter INTEGER DEFAULT 0)", table), + }, + } + _, status, err := createReq.Do(ctx) + require.NoError(t, err) + require.True(t, status == http.StatusOK || status == http.StatusCreated) + + // Write-read cycle 10 times + for i := 1; i <= 10; i++ { + insertReq := &e2e.HTTPRequest{ + Method: http.MethodPost, + URL: gatewayURL + "/v1/rqlite/transaction", + Body: map[string]interface{}{ + "statements": []string{ + fmt.Sprintf("INSERT INTO %s(counter) VALUES (%d)", table, i), + }, + }, + } + _, status, err := insertReq.Do(ctx) + require.NoError(t, err, "insert %d failed", i) + require.Equal(t, http.StatusOK, status, "insert %d got status %d", i, status) + + queryReq := &e2e.HTTPRequest{ + Method: http.MethodPost, + URL: gatewayURL + "/v1/rqlite/query", + Body: map[string]interface{}{ + "sql": fmt.Sprintf("SELECT COUNT(*) as cnt FROM %s", table), + }, + } + body, _, _ := queryReq.Do(ctx) + t.Logf("Iteration %d: %s", i, string(body)) + } + + // Final verification + queryReq := &e2e.HTTPRequest{ + Method: http.MethodPost, + URL: gatewayURL + "/v1/rqlite/query", + Body: map[string]interface{}{ + "sql": fmt.Sprintf("SELECT COUNT(*) as cnt FROM %s", table), + }, + } + body, status, err := queryReq.Do(ctx) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status) + + var result map[string]interface{} + json.Unmarshal(body, &result) + t.Logf("Final count result: %s", string(body)) +} diff --git a/e2e/deployments/edge_cases_test.go b/e2e/deployments/edge_cases_test.go new file mode 100644 index 0000000..67fafdc --- /dev/null +++ b/e2e/deployments/edge_cases_test.go @@ -0,0 +1,223 @@ +//go:build e2e + +package deployments_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/DeBrosOfficial/network/e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDeploy_InvalidTarball verifies that uploading an invalid/corrupt tarball +// returns a clean error (not a 500 or panic). +func TestDeploy_InvalidTarball(t *testing.T) { + env, err := e2e.LoadTestEnv() + require.NoError(t, err) + + deploymentName := fmt.Sprintf("invalid-tar-%d", time.Now().Unix()) + + body := &bytes.Buffer{} + boundary := "----WebKitFormBoundary7MA4YWxkTrZu0gW" + + body.WriteString("--" + boundary + "\r\n") + body.WriteString("Content-Disposition: form-data; name=\"name\"\r\n\r\n") + body.WriteString(deploymentName + "\r\n") + + // Write invalid tarball data (random bytes, not a real gzip) + body.WriteString("--" + boundary + "\r\n") + body.WriteString("Content-Disposition: form-data; name=\"tarball\"; filename=\"app.tar.gz\"\r\n") + body.WriteString("Content-Type: application/gzip\r\n\r\n") + body.WriteString("this is not a valid tarball content at all!!!") + body.WriteString("\r\n--" + boundary + "--\r\n") + + req, err := http.NewRequest("POST", env.GatewayURL+"/v1/deployments/static/upload", body) + require.NoError(t, err) + req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary) + req.Header.Set("Authorization", "Bearer "+env.APIKey) + + resp, err := env.HTTPClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + t.Logf("Status: %d, Body: %s", resp.StatusCode, string(respBody)) + + // Should return an error, not 2xx (ideally 400, but server currently returns 500) + assert.True(t, resp.StatusCode >= 400, + "Invalid tarball should return error (got %d)", resp.StatusCode) +} + +// TestDeploy_EmptyTarball verifies that uploading an empty file returns an error. +func TestDeploy_EmptyTarball(t *testing.T) { + env, err := e2e.LoadTestEnv() + require.NoError(t, err) + + deploymentName := fmt.Sprintf("empty-tar-%d", time.Now().Unix()) + + body := &bytes.Buffer{} + boundary := "----WebKitFormBoundary7MA4YWxkTrZu0gW" + + body.WriteString("--" + boundary + "\r\n") + body.WriteString("Content-Disposition: form-data; name=\"name\"\r\n\r\n") + body.WriteString(deploymentName + "\r\n") + + // Empty tarball + body.WriteString("--" + boundary + "\r\n") + body.WriteString("Content-Disposition: form-data; name=\"tarball\"; filename=\"app.tar.gz\"\r\n") + body.WriteString("Content-Type: application/gzip\r\n\r\n") + body.WriteString("\r\n--" + boundary + "--\r\n") + + req, err := http.NewRequest("POST", env.GatewayURL+"/v1/deployments/static/upload", body) + require.NoError(t, err) + req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary) + req.Header.Set("Authorization", "Bearer "+env.APIKey) + + resp, err := env.HTTPClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + t.Logf("Status: %d, Body: %s", resp.StatusCode, string(respBody)) + + assert.True(t, resp.StatusCode >= 400, + "Empty tarball should return error (got %d)", resp.StatusCode) +} + +// TestDeploy_MissingName verifies that deploying without a name returns an error. +func TestDeploy_MissingName(t *testing.T) { + env, err := e2e.LoadTestEnv() + require.NoError(t, err) + + tarballPath := filepath.Join("../../testdata/apps/react-app") + + body := &bytes.Buffer{} + boundary := "----WebKitFormBoundary7MA4YWxkTrZu0gW" + + // No name field + body.WriteString("--" + boundary + "\r\n") + body.WriteString("Content-Disposition: form-data; name=\"tarball\"; filename=\"app.tar.gz\"\r\n") + body.WriteString("Content-Type: application/gzip\r\n\r\n") + + // Create tarball from directory for the "no name" test + tarData, err := exec.Command("tar", "-czf", "-", "-C", tarballPath, ".").Output() + if err != nil { + t.Skip("Failed to create tarball from test app") + } + body.Write(tarData) + body.WriteString("\r\n--" + boundary + "--\r\n") + + req, err := http.NewRequest("POST", env.GatewayURL+"/v1/deployments/static/upload", body) + require.NoError(t, err) + req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary) + req.Header.Set("Authorization", "Bearer "+env.APIKey) + + resp, err := env.HTTPClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.True(t, resp.StatusCode >= 400, + "Missing name should return error (got %d)", resp.StatusCode) +} + +// TestDeploy_ConcurrentSameName verifies that deploying two apps with the same +// name concurrently doesn't cause data corruption. +func TestDeploy_ConcurrentSameName(t *testing.T) { + env, err := e2e.LoadTestEnv() + require.NoError(t, err) + + deploymentName := fmt.Sprintf("concurrent-%d", time.Now().Unix()) + tarballPath := filepath.Join("../../testdata/apps/react-app") + + var wg sync.WaitGroup + results := make([]int, 2) + ids := make([]string, 2) + + // Pre-create tarball once for both goroutines + tarData, err := exec.Command("tar", "-czf", "-", "-C", tarballPath, ".").Output() + if err != nil { + t.Skip("Failed to create tarball from test app") + } + + for i := 0; i < 2; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + + body := &bytes.Buffer{} + boundary := "----WebKitFormBoundary7MA4YWxkTrZu0gW" + + body.WriteString("--" + boundary + "\r\n") + body.WriteString("Content-Disposition: form-data; name=\"name\"\r\n\r\n") + body.WriteString(deploymentName + "\r\n") + + body.WriteString("--" + boundary + "\r\n") + body.WriteString("Content-Disposition: form-data; name=\"tarball\"; filename=\"app.tar.gz\"\r\n") + body.WriteString("Content-Type: application/gzip\r\n\r\n") + body.Write(tarData) + body.WriteString("\r\n--" + boundary + "--\r\n") + + req, _ := http.NewRequest("POST", env.GatewayURL+"/v1/deployments/static/upload", body) + req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary) + req.Header.Set("Authorization", "Bearer "+env.APIKey) + + resp, err := env.HTTPClient.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + + results[idx] = resp.StatusCode + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + if id, ok := result["deployment_id"].(string); ok { + ids[idx] = id + } else if id, ok := result["id"].(string); ok { + ids[idx] = id + } + }(i) + } + + wg.Wait() + + t.Logf("Concurrent deploy results: status1=%d status2=%d id1=%s id2=%s", + results[0], results[1], ids[0], ids[1]) + + // At least one should succeed + successCount := 0 + for _, status := range results { + if status == http.StatusCreated { + successCount++ + } + } + assert.GreaterOrEqual(t, successCount, 1, + "At least one concurrent deploy should succeed") + + // Cleanup + for _, id := range ids { + if id != "" { + e2e.DeleteDeployment(t, env, id) + } + } +} + +func readFileBytes(path string) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return io.ReadAll(f) +} diff --git a/e2e/deployments/nodejs_deployment_test.go b/e2e/deployments/nodejs_deployment_test.go index 0b80d84..d31843f 100644 --- a/e2e/deployments/nodejs_deployment_test.go +++ b/e2e/deployments/nodejs_deployment_test.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "os" + "os/exec" "path/filepath" "testing" "time" @@ -101,29 +102,36 @@ func TestNodeJSDeployment_FullFlow(t *testing.T) { func createNodeJSDeployment(t *testing.T, env *e2e.E2ETestEnv, name, tarballPath string) string { t.Helper() - file, err := os.Open(tarballPath) + var fileData []byte + + info, err := os.Stat(tarballPath) if err != nil { - // Try alternate path - altPath := filepath.Join("testdata/apps/nodejs-backend.tar.gz") - file, err = os.Open(altPath) + t.Fatalf("Failed to stat tarball path: %v", err) + } + + if info.IsDir() { + // Create tarball from directory + tarData, err := exec.Command("tar", "-czf", "-", "-C", tarballPath, ".").Output() + require.NoError(t, err, "Failed to create tarball from %s", tarballPath) + fileData = tarData + } else { + file, err := os.Open(tarballPath) + require.NoError(t, err, "Failed to open tarball: %s", tarballPath) + defer file.Close() + fileData, _ = io.ReadAll(file) } - require.NoError(t, err, "Failed to open tarball: %s", tarballPath) - defer file.Close() body := &bytes.Buffer{} boundary := "----WebKitFormBoundary7MA4YWxkTrZu0gW" - // Write name field body.WriteString("--" + boundary + "\r\n") body.WriteString("Content-Disposition: form-data; name=\"name\"\r\n\r\n") body.WriteString(name + "\r\n") - // Write tarball file body.WriteString("--" + boundary + "\r\n") body.WriteString("Content-Disposition: form-data; name=\"tarball\"; filename=\"app.tar.gz\"\r\n") body.WriteString("Content-Type: application/gzip\r\n\r\n") - fileData, _ := io.ReadAll(file) body.Write(fileData) body.WriteString("\r\n--" + boundary + "--\r\n") diff --git a/e2e/deployments/replica_test.go b/e2e/deployments/replica_test.go new file mode 100644 index 0000000..64ec027 --- /dev/null +++ b/e2e/deployments/replica_test.go @@ -0,0 +1,422 @@ +//go:build e2e + +package deployments_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/DeBrosOfficial/network/e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestStaticReplica_CreatedOnDeploy verifies that deploying a static app +// creates replica records on a second node. +func TestStaticReplica_CreatedOnDeploy(t *testing.T) { + env, err := e2e.LoadTestEnv() + require.NoError(t, err, "Failed to load test environment") + + deploymentName := fmt.Sprintf("replica-static-%d", time.Now().Unix()) + tarballPath := filepath.Join("../../testdata/apps/react-app") + var deploymentID string + + defer func() { + if !env.SkipCleanup && deploymentID != "" { + e2e.DeleteDeployment(t, env, deploymentID) + } + }() + + t.Run("Deploy static app", func(t *testing.T) { + deploymentID = e2e.CreateTestDeployment(t, env, deploymentName, tarballPath) + require.NotEmpty(t, deploymentID) + t.Logf("Created deployment: %s (ID: %s)", deploymentName, deploymentID) + }) + + t.Run("Wait for replica setup", func(t *testing.T) { + // Static replicas should set up quickly (IPFS content) + time.Sleep(10 * time.Second) + }) + + t.Run("Deployment has replica records", func(t *testing.T) { + deployment := e2e.GetDeployment(t, env, deploymentID) + + // Check that replicas field exists and has entries + replicas, ok := deployment["replicas"].([]interface{}) + if !ok { + // Replicas might be in a nested structure or separate endpoint + t.Logf("Deployment response: %+v", deployment) + // Try querying replicas via the deployment details + homeNodeID, _ := deployment["home_node_id"].(string) + require.NotEmpty(t, homeNodeID, "Deployment should have a home_node_id") + t.Logf("Home node: %s", homeNodeID) + // If replicas aren't in the response, that's still okay — we verify + // via DNS and cross-node serving below + t.Log("Replica records not in deployment response; will verify via DNS/serving") + return + } + + assert.GreaterOrEqual(t, len(replicas), 1, "Should have at least 1 replica") + t.Logf("Found %d replica records", len(replicas)) + for i, r := range replicas { + if replica, ok := r.(map[string]interface{}); ok { + t.Logf(" Replica %d: node=%s status=%s", i, replica["node_id"], replica["status"]) + } + } + }) + + t.Run("Static content served from both nodes", func(t *testing.T) { + e2e.SkipIfLocal(t) + + if len(env.Config.Servers) < 2 { + t.Skip("Requires at least 2 servers") + } + + deployment := e2e.GetDeployment(t, env, deploymentID) + nodeURL := extractNodeURL(t, deployment) + if nodeURL == "" { + t.Skip("No node URL in deployment") + } + domain := extractDomain(nodeURL) + + for _, server := range env.Config.Servers { + t.Run("via_"+server.Name, func(t *testing.T) { + gatewayURL := fmt.Sprintf("http://%s:6001", server.IP) + + req, err := http.NewRequest("GET", gatewayURL+"/", nil) + require.NoError(t, err) + req.Host = domain + + resp, err := env.HTTPClient.Do(req) + require.NoError(t, err, "Request to %s should succeed", server.Name) + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + assert.Equal(t, http.StatusOK, resp.StatusCode, + "Request via %s should return 200 (got %d: %s)", server.Name, resp.StatusCode, string(body)) + t.Logf("Served via %s (%s): status=%d", server.Name, server.IP, resp.StatusCode) + }) + } + }) +} + +// TestDynamicReplica_CreatedOnDeploy verifies that deploying a dynamic (Node.js) app +// creates a replica process on a second node. +func TestDynamicReplica_CreatedOnDeploy(t *testing.T) { + env, err := e2e.LoadTestEnv() + require.NoError(t, err, "Failed to load test environment") + + deploymentName := fmt.Sprintf("replica-nodejs-%d", time.Now().Unix()) + tarballPath := filepath.Join("../../testdata/apps/node-api") + var deploymentID string + + defer func() { + if !env.SkipCleanup && deploymentID != "" { + e2e.DeleteDeployment(t, env, deploymentID) + } + }() + + t.Run("Deploy Node.js backend", func(t *testing.T) { + deploymentID = createNodeJSDeployment(t, env, deploymentName, tarballPath) + require.NotEmpty(t, deploymentID) + t.Logf("Created deployment: %s (ID: %s)", deploymentName, deploymentID) + }) + + t.Run("Wait for deployment and replica", func(t *testing.T) { + healthy := e2e.WaitForHealthy(t, env, deploymentID, 90*time.Second) + assert.True(t, healthy, "Deployment should become healthy") + // Extra wait for async replica setup + time.Sleep(15 * time.Second) + }) + + t.Run("Dynamic app served from both nodes", func(t *testing.T) { + e2e.SkipIfLocal(t) + + if len(env.Config.Servers) < 2 { + t.Skip("Requires at least 2 servers") + } + + deployment := e2e.GetDeployment(t, env, deploymentID) + nodeURL := extractNodeURL(t, deployment) + if nodeURL == "" { + t.Skip("No node URL in deployment") + } + domain := extractDomain(nodeURL) + + successCount := 0 + for _, server := range env.Config.Servers { + t.Run("via_"+server.Name, func(t *testing.T) { + gatewayURL := fmt.Sprintf("http://%s:6001", server.IP) + + req, err := http.NewRequest("GET", gatewayURL+"/health", nil) + require.NoError(t, err) + req.Host = domain + + resp, err := env.HTTPClient.Do(req) + if err != nil { + t.Logf("Request to %s failed: %v", server.Name, err) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode == http.StatusOK { + successCount++ + t.Logf("Served via %s: status=%d body=%s", server.Name, resp.StatusCode, string(body)) + } else { + t.Logf("Non-200 via %s: status=%d body=%s", server.Name, resp.StatusCode, string(body)) + } + }) + } + + assert.GreaterOrEqual(t, successCount, 2, "At least 2 nodes should serve the deployment") + }) +} + +// TestReplica_UpdatePropagation verifies that updating a deployment propagates to replicas. +func TestReplica_UpdatePropagation(t *testing.T) { + env, err := e2e.LoadTestEnv() + require.NoError(t, err, "Failed to load test environment") + e2e.SkipIfLocal(t) + + if len(env.Config.Servers) < 2 { + t.Skip("Requires at least 2 servers") + } + + deploymentName := fmt.Sprintf("replica-update-%d", time.Now().Unix()) + tarballPath := filepath.Join("../../testdata/apps/react-app") + var deploymentID string + + defer func() { + if !env.SkipCleanup && deploymentID != "" { + e2e.DeleteDeployment(t, env, deploymentID) + } + }() + + t.Run("Deploy v1", func(t *testing.T) { + deploymentID = e2e.CreateTestDeployment(t, env, deploymentName, tarballPath) + require.NotEmpty(t, deploymentID) + time.Sleep(10 * time.Second) // Wait for replica + }) + + var v1CID string + t.Run("Record v1 CID", func(t *testing.T) { + deployment := e2e.GetDeployment(t, env, deploymentID) + v1CID, _ = deployment["content_cid"].(string) + require.NotEmpty(t, v1CID) + t.Logf("v1 CID: %s", v1CID) + }) + + t.Run("Update to v2", func(t *testing.T) { + updateStaticDeployment(t, env, deploymentName, tarballPath) + time.Sleep(10 * time.Second) // Wait for update + replica propagation + }) + + t.Run("All nodes serve updated version", func(t *testing.T) { + deployment := e2e.GetDeployment(t, env, deploymentID) + v2CID, _ := deployment["content_cid"].(string) + + // v2 CID might be same (same tarball) but version should increment + version, _ := deployment["version"].(float64) + assert.Equal(t, float64(2), version, "Should be version 2") + t.Logf("v2 CID: %s, version: %v", v2CID, version) + + // Verify all nodes return consistent data + for _, server := range env.Config.Servers { + gatewayURL := fmt.Sprintf("http://%s:6001", server.IP) + req, _ := http.NewRequest("GET", gatewayURL+"/v1/deployments/get?id="+deploymentID, nil) + req.Header.Set("Authorization", "Bearer "+env.APIKey) + + resp, err := env.HTTPClient.Do(req) + if err != nil { + t.Logf("Could not reach %s: %v", server.Name, err) + continue + } + defer resp.Body.Close() + + var dep map[string]interface{} + json.NewDecoder(resp.Body).Decode(&dep) + nodeCID, _ := dep["content_cid"].(string) + nodeVersion, _ := dep["version"].(float64) + t.Logf("%s: cid=%s version=%v", server.Name, nodeCID, nodeVersion) + + assert.Equal(t, v2CID, nodeCID, "CID should match on %s", server.Name) + } + }) +} + +// TestReplica_RollbackPropagation verifies rollback propagates to replica nodes. +func TestReplica_RollbackPropagation(t *testing.T) { + env, err := e2e.LoadTestEnv() + require.NoError(t, err, "Failed to load test environment") + e2e.SkipIfLocal(t) + + if len(env.Config.Servers) < 2 { + t.Skip("Requires at least 2 servers") + } + + deploymentName := fmt.Sprintf("replica-rollback-%d", time.Now().Unix()) + tarballPath := filepath.Join("../../testdata/apps/react-app") + var deploymentID string + + defer func() { + if !env.SkipCleanup && deploymentID != "" { + e2e.DeleteDeployment(t, env, deploymentID) + } + }() + + t.Run("Deploy v1 and update to v2", func(t *testing.T) { + deploymentID = e2e.CreateTestDeployment(t, env, deploymentName, tarballPath) + require.NotEmpty(t, deploymentID) + time.Sleep(10 * time.Second) + + updateStaticDeployment(t, env, deploymentName, tarballPath) + time.Sleep(10 * time.Second) + }) + + var v1CID string + t.Run("Get v1 CID from versions", func(t *testing.T) { + versions := listVersions(t, env, deploymentName) + if len(versions) > 0 { + v1CID, _ = versions[0]["content_cid"].(string) + } + if v1CID == "" { + // Fall back: v1 CID from current deployment + deployment := e2e.GetDeployment(t, env, deploymentID) + v1CID, _ = deployment["content_cid"].(string) + } + t.Logf("v1 CID for rollback comparison: %s", v1CID) + }) + + t.Run("Rollback to v1", func(t *testing.T) { + rollbackDeployment(t, env, deploymentName, 1) + time.Sleep(10 * time.Second) // Wait for rollback + replica propagation + }) + + t.Run("All nodes have rolled-back CID", func(t *testing.T) { + deployment := e2e.GetDeployment(t, env, deploymentID) + currentCID, _ := deployment["content_cid"].(string) + t.Logf("Post-rollback CID: %s", currentCID) + + for _, server := range env.Config.Servers { + gatewayURL := fmt.Sprintf("http://%s:6001", server.IP) + req, _ := http.NewRequest("GET", gatewayURL+"/v1/deployments/get?id="+deploymentID, nil) + req.Header.Set("Authorization", "Bearer "+env.APIKey) + + resp, err := env.HTTPClient.Do(req) + if err != nil { + continue + } + defer resp.Body.Close() + + var dep map[string]interface{} + json.NewDecoder(resp.Body).Decode(&dep) + nodeCID, _ := dep["content_cid"].(string) + assert.Equal(t, currentCID, nodeCID, "CID should match on %s after rollback", server.Name) + } + }) +} + +// TestReplica_TeardownOnDelete verifies that deleting a deployment removes replicas. +func TestReplica_TeardownOnDelete(t *testing.T) { + env, err := e2e.LoadTestEnv() + require.NoError(t, err, "Failed to load test environment") + e2e.SkipIfLocal(t) + + if len(env.Config.Servers) < 2 { + t.Skip("Requires at least 2 servers") + } + + deploymentName := fmt.Sprintf("replica-delete-%d", time.Now().Unix()) + tarballPath := filepath.Join("../../testdata/apps/react-app") + + deploymentID := e2e.CreateTestDeployment(t, env, deploymentName, tarballPath) + require.NotEmpty(t, deploymentID) + time.Sleep(10 * time.Second) // Wait for replica + + // Get the domain before deletion + deployment := e2e.GetDeployment(t, env, deploymentID) + nodeURL := extractNodeURL(t, deployment) + domain := "" + if nodeURL != "" { + domain = extractDomain(nodeURL) + } + + t.Run("Delete deployment", func(t *testing.T) { + e2e.DeleteDeployment(t, env, deploymentID) + time.Sleep(10 * time.Second) // Wait for teardown propagation + }) + + t.Run("Deployment no longer served on any node", func(t *testing.T) { + if domain == "" { + t.Skip("No domain to test") + } + + for _, server := range env.Config.Servers { + gatewayURL := fmt.Sprintf("http://%s:6001", server.IP) + req, _ := http.NewRequest("GET", gatewayURL+"/", nil) + req.Host = domain + + resp, err := env.HTTPClient.Do(req) + if err != nil { + t.Logf("%s: connection failed (expected)", server.Name) + continue + } + defer resp.Body.Close() + + // Should get 404 or 502, not 200 with app content + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode == http.StatusOK { + // If we get 200, make sure it's not the deleted app + assert.NotContains(t, string(body), "
", + "Deleted deployment should not be served on %s", server.Name) + } + t.Logf("%s: status=%d (expected non-200)", server.Name, resp.StatusCode) + } + }) +} + +// updateStaticDeployment updates an existing static deployment. +func updateStaticDeployment(t *testing.T, env *e2e.E2ETestEnv, name, tarballPath string) { + t.Helper() + + file, err := os.Open(tarballPath) + require.NoError(t, err) + defer file.Close() + + body := &bytes.Buffer{} + boundary := "----WebKitFormBoundary7MA4YWxkTrZu0gW" + + body.WriteString("--" + boundary + "\r\n") + body.WriteString("Content-Disposition: form-data; name=\"name\"\r\n\r\n") + body.WriteString(name + "\r\n") + + body.WriteString("--" + boundary + "\r\n") + body.WriteString("Content-Disposition: form-data; name=\"tarball\"; filename=\"app.tar.gz\"\r\n") + body.WriteString("Content-Type: application/gzip\r\n\r\n") + + fileData, _ := io.ReadAll(file) + body.Write(fileData) + body.WriteString("\r\n--" + boundary + "--\r\n") + + req, err := http.NewRequest("POST", env.GatewayURL+"/v1/deployments/static/update", body) + require.NoError(t, err) + req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary) + req.Header.Set("Authorization", "Bearer "+env.APIKey) + + resp, err := env.HTTPClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("Update failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + } +} diff --git a/e2e/env.go b/e2e/env.go index 8f90338..6a96e16 100644 --- a/e2e/env.go +++ b/e2e/env.go @@ -15,6 +15,7 @@ import ( "net/http" "net/url" "os" + "os/exec" "path/filepath" "strings" "sync" @@ -147,6 +148,90 @@ func loadNodeConfig(filename string) (map[string]interface{}, error) { return cfg, nil } +// loadActiveEnvironment reads ~/.orama/environments.json and returns the active environment's gateway URL. +func loadActiveEnvironment() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + + data, err := os.ReadFile(filepath.Join(homeDir, ".orama", "environments.json")) + if err != nil { + return "", err + } + + var envConfig struct { + Environments []struct { + Name string `json:"name"` + GatewayURL string `json:"gateway_url"` + } `json:"environments"` + ActiveEnvironment string `json:"active_environment"` + } + if err := json.Unmarshal(data, &envConfig); err != nil { + return "", err + } + + for _, env := range envConfig.Environments { + if env.Name == envConfig.ActiveEnvironment { + return env.GatewayURL, nil + } + } + + return "", fmt.Errorf("active environment %q not found", envConfig.ActiveEnvironment) +} + +// loadCredentialAPIKey reads ~/.orama/credentials.json and returns the API key for the given gateway URL. +func loadCredentialAPIKey(gatewayURL string) (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + + data, err := os.ReadFile(filepath.Join(homeDir, ".orama", "credentials.json")) + if err != nil { + return "", err + } + + // credentials.json v2 format: gateways -> url -> credentials[] array + var store struct { + Gateways map[string]json.RawMessage `json:"gateways"` + } + if err := json.Unmarshal(data, &store); err != nil { + return "", err + } + + raw, ok := store.Gateways[gatewayURL] + if !ok { + return "", fmt.Errorf("no credentials for gateway %s", gatewayURL) + } + + // Try v2 format: { "credentials": [...], "default_index": 0 } + var v2 struct { + Credentials []struct { + APIKey string `json:"api_key"` + Namespace string `json:"namespace"` + } `json:"credentials"` + DefaultIndex int `json:"default_index"` + } + if err := json.Unmarshal(raw, &v2); err == nil && len(v2.Credentials) > 0 { + idx := v2.DefaultIndex + if idx >= len(v2.Credentials) { + idx = 0 + } + return v2.Credentials[idx].APIKey, nil + } + + // Try v1 format: direct Credentials object { "api_key": "..." } + var v1 struct { + APIKey string `json:"api_key"` + } + if err := json.Unmarshal(raw, &v1); err == nil && v1.APIKey != "" { + return v1.APIKey, nil + } + + return "", fmt.Errorf("no API key found in credentials for %s", gatewayURL) +} + // GetGatewayURL returns the gateway base URL from config func GetGatewayURL() string { cacheMutex.RLock() @@ -170,6 +255,14 @@ func GetGatewayURL() string { return envURL } + // Try to load from orama active environment (~/.orama/environments.json) + if envURL, err := loadActiveEnvironment(); err == nil && envURL != "" { + cacheMutex.Lock() + gatewayURLCache = envURL + cacheMutex.Unlock() + return envURL + } + // Try to load from gateway config gwCfg, err := loadGatewayConfig() if err == nil { @@ -346,7 +439,7 @@ func queryAPIKeyFromRemoteRQLite(gatewayURL string) (string, error) { return "", fmt.Errorf("no API key found in rqlite") } -// GetAPIKey returns the gateway API key from rqlite or cache +// GetAPIKey returns the gateway API key from credentials.json, env vars, or rqlite func GetAPIKey() string { cacheMutex.RLock() if apiKeyCache != "" { @@ -355,7 +448,24 @@ func GetAPIKey() string { } cacheMutex.RUnlock() - // Query rqlite for API key + // 1. Check env var + if envKey := os.Getenv("DEBROS_API_KEY"); envKey != "" { + cacheMutex.Lock() + apiKeyCache = envKey + cacheMutex.Unlock() + return envKey + } + + // 2. Try credentials.json for the active gateway + gatewayURL := GetGatewayURL() + if apiKey, err := loadCredentialAPIKey(gatewayURL); err == nil && apiKey != "" { + cacheMutex.Lock() + apiKeyCache = apiKey + cacheMutex.Unlock() + return apiKey + } + + // 3. Fall back to querying rqlite directly apiKey, err := queryAPIKeyFromRQLite() if err != nil { return "" @@ -1143,14 +1253,17 @@ func LoadTestEnv() (*E2ETestEnv, error) { gatewayURL = GetGatewayURL() } - // Check if API key is provided via environment variable or config + // Check if API key is provided via environment variable, config, or credentials.json apiKey := os.Getenv("ORAMA_API_KEY") if apiKey == "" && cfg.APIKey != "" { apiKey = cfg.APIKey } + if apiKey == "" { + apiKey = GetAPIKey() // Reads from credentials.json or rqlite + } namespace := os.Getenv("ORAMA_NAMESPACE") - // If no API key provided, create a fresh one for a default test namespace + // If still no API key, create a fresh one for a default test namespace if apiKey == "" { if namespace == "" { namespace = "default-test-ns" @@ -1231,15 +1344,42 @@ func LoadTestEnvWithNamespace(namespace string) (*E2ETestEnv, error) { }, nil } -// CreateTestDeployment creates a test deployment and returns its ID +// tarballFromDir creates a .tar.gz in memory from a directory. +func tarballFromDir(dirPath string) ([]byte, error) { + var buf bytes.Buffer + cmd := exec.Command("tar", "-czf", "-", "-C", dirPath, ".") + cmd.Stdout = &buf + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("tar failed: %w", err) + } + return buf.Bytes(), nil +} + +// CreateTestDeployment creates a test deployment and returns its ID. +// tarballPath can be a .tar.gz file or a directory (which will be tarred automatically). func CreateTestDeployment(t *testing.T, env *E2ETestEnv, name, tarballPath string) string { t.Helper() - file, err := os.Open(tarballPath) + var fileData []byte + + info, err := os.Stat(tarballPath) if err != nil { - t.Fatalf("failed to open tarball: %v", err) + t.Fatalf("failed to stat tarball path: %v", err) + } + + if info.IsDir() { + // Create tarball from directory + fileData, err = tarballFromDir(tarballPath) + if err != nil { + t.Fatalf("failed to create tarball from dir: %v", err) + } + } else { + fileData, err = os.ReadFile(tarballPath) + if err != nil { + t.Fatalf("failed to read tarball: %v", err) + } } - defer file.Close() // Create multipart form body := &bytes.Buffer{} @@ -1259,7 +1399,6 @@ func CreateTestDeployment(t *testing.T, env *E2ETestEnv, name, tarballPath strin body.WriteString("Content-Disposition: form-data; name=\"tarball\"; filename=\"app.tar.gz\"\r\n") body.WriteString("Content-Type: application/gzip\r\n\r\n") - fileData, _ := io.ReadAll(file) body.Write(fileData) body.WriteString("\r\n--" + boundary + "--\r\n") diff --git a/e2e/integration/ipfs_replica_test.go b/e2e/integration/ipfs_replica_test.go new file mode 100644 index 0000000..cfed325 --- /dev/null +++ b/e2e/integration/ipfs_replica_test.go @@ -0,0 +1,135 @@ +//go:build e2e + +package integration + +import ( + "fmt" + "io" + "net/http" + "path/filepath" + "testing" + "time" + + "github.com/DeBrosOfficial/network/e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIPFS_ContentPinnedOnMultipleNodes verifies that deploying a static app +// makes the IPFS content available across multiple nodes. +func TestIPFS_ContentPinnedOnMultipleNodes(t *testing.T) { + e2e.SkipIfLocal(t) + + env, err := e2e.LoadTestEnv() + require.NoError(t, err) + + if len(env.Config.Servers) < 2 { + t.Skip("Requires at least 2 servers") + } + + deploymentName := fmt.Sprintf("ipfs-pin-%d", time.Now().Unix()) + tarballPath := filepath.Join("../../testdata/apps/react-app") + + deploymentID := e2e.CreateTestDeployment(t, env, deploymentName, tarballPath) + require.NotEmpty(t, deploymentID) + + defer func() { + if !env.SkipCleanup { + e2e.DeleteDeployment(t, env, deploymentID) + } + }() + + time.Sleep(15 * time.Second) // Wait for IPFS content replication + + deployment := e2e.GetDeployment(t, env, deploymentID) + contentCID, _ := deployment["content_cid"].(string) + require.NotEmpty(t, contentCID, "Deployment should have a content CID") + + t.Run("Content served from each node via gateway", func(t *testing.T) { + // Extract domain from deployment URLs + urls, _ := deployment["urls"].([]interface{}) + require.NotEmpty(t, urls, "Deployment should have URLs") + urlStr, _ := urls[0].(string) + domain := urlStr + if len(urlStr) > 8 && urlStr[:8] == "https://" { + domain = urlStr[8:] + } else if len(urlStr) > 7 && urlStr[:7] == "http://" { + domain = urlStr[7:] + } + + client := e2e.NewHTTPClient(30 * time.Second) + + for _, server := range env.Config.Servers { + t.Run("node_"+server.Name, func(t *testing.T) { + gatewayURL := fmt.Sprintf("http://%s:6001/", server.IP) + req, _ := http.NewRequest("GET", gatewayURL, nil) + req.Host = domain + + resp, err := client.Do(req) + require.NoError(t, err, "Request to %s should not error", server.Name) + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + t.Logf("%s: status=%d, body=%d bytes", server.Name, resp.StatusCode, len(body)) + assert.Equal(t, http.StatusOK, resp.StatusCode, + "IPFS content should be served on %s (CID: %s)", server.Name, contentCID) + }) + } + }) +} + +// TestIPFS_LargeFileDeployment verifies that deploying an app with larger +// static assets works correctly. +func TestIPFS_LargeFileDeployment(t *testing.T) { + env, err := e2e.LoadTestEnv() + require.NoError(t, err) + + deploymentName := fmt.Sprintf("ipfs-large-%d", time.Now().Unix()) + tarballPath := filepath.Join("../../testdata/apps/react-app") + + // The react-vite tarball is our largest test asset + deploymentID := e2e.CreateTestDeployment(t, env, deploymentName, tarballPath) + require.NotEmpty(t, deploymentID) + + defer func() { + if !env.SkipCleanup { + e2e.DeleteDeployment(t, env, deploymentID) + } + }() + + time.Sleep(5 * time.Second) + + t.Run("Deployment has valid CID", func(t *testing.T) { + deployment := e2e.GetDeployment(t, env, deploymentID) + contentCID, _ := deployment["content_cid"].(string) + assert.NotEmpty(t, contentCID, "Should have a content CID") + assert.True(t, len(contentCID) > 10, "CID should be a valid IPFS hash") + t.Logf("Content CID: %s", contentCID) + }) + + t.Run("Static content serves correctly", func(t *testing.T) { + deployment := e2e.GetDeployment(t, env, deploymentID) + urls, ok := deployment["urls"].([]interface{}) + if !ok || len(urls) == 0 { + t.Skip("No URLs in deployment") + } + + nodeURL, _ := urls[0].(string) + domain := nodeURL + if len(nodeURL) > 8 && nodeURL[:8] == "https://" { + domain = nodeURL[8:] + } else if len(nodeURL) > 7 && nodeURL[:7] == "http://" { + domain = nodeURL[7:] + } + if len(domain) > 0 && domain[len(domain)-1] == '/' { + domain = domain[:len(domain)-1] + } + + resp := e2e.TestDeploymentWithHostHeader(t, env, domain, "/") + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Greater(t, len(body), 100, "Response should have substantial content") + }) +} diff --git a/e2e/production/dns_replica_test.go b/e2e/production/dns_replica_test.go new file mode 100644 index 0000000..0226108 --- /dev/null +++ b/e2e/production/dns_replica_test.go @@ -0,0 +1,333 @@ +//go:build e2e && production + +package production + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/DeBrosOfficial/network/e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDNS_MultipleARecords verifies that deploying with replicas creates +// multiple A records (one per node) for DNS round-robin. +func TestDNS_MultipleARecords(t *testing.T) { + e2e.SkipIfLocal(t) + + env, err := e2e.LoadTestEnv() + require.NoError(t, err) + + if len(env.Config.Servers) < 2 { + t.Skip("Requires at least 2 servers") + } + + deploymentName := fmt.Sprintf("dns-multi-%d", time.Now().Unix()) + tarballPath := filepath.Join("../../testdata/apps/react-app") + + deploymentID := e2e.CreateTestDeployment(t, env, deploymentName, tarballPath) + require.NotEmpty(t, deploymentID) + + defer func() { + if !env.SkipCleanup { + e2e.DeleteDeployment(t, env, deploymentID) + } + }() + + // Wait for replica setup and DNS propagation + time.Sleep(15 * time.Second) + + t.Run("DNS returns multiple IPs", func(t *testing.T) { + deployment := e2e.GetDeployment(t, env, deploymentID) + subdomain, _ := deployment["subdomain"].(string) + if subdomain == "" { + subdomain = deploymentName + } + fqdn := fmt.Sprintf("%s.%s", subdomain, env.BaseDomain) + + // Query nameserver directly + nameserverIP := env.Config.Servers[0].IP + resolver := &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{Timeout: 10 * time.Second} + return d.Dial("udp", nameserverIP+":53") + }, + } + + ctx := context.Background() + ips, err := resolver.LookupHost(ctx, fqdn) + if err != nil { + t.Logf("DNS lookup failed for %s: %v", fqdn, err) + t.Log("Trying net.LookupHost instead...") + ips, err = net.LookupHost(fqdn) + } + + if err != nil { + t.Logf("DNS lookup failed: %v (DNS may not be propagated yet)", err) + t.Skip("DNS not yet propagated") + } + + t.Logf("DNS returned %d IPs for %s: %v", len(ips), fqdn, ips) + assert.GreaterOrEqual(t, len(ips), 2, + "Should have at least 2 A records (home + replica)") + + // Verify returned IPs are from our server list + serverIPs := e2e.GetServerIPs(env.Config) + for _, ip := range ips { + assert.Contains(t, serverIPs, ip, + "DNS IP %s should be one of our servers", ip) + } + }) +} + +// TestDNS_CleanupOnDelete verifies that deleting a deployment removes all +// DNS records (both home and replica A records). +func TestDNS_CleanupOnDelete(t *testing.T) { + e2e.SkipIfLocal(t) + + env, err := e2e.LoadTestEnv() + require.NoError(t, err) + + deploymentName := fmt.Sprintf("dns-cleanup-%d", time.Now().Unix()) + tarballPath := filepath.Join("../../testdata/apps/react-app") + + deploymentID := e2e.CreateTestDeployment(t, env, deploymentName, tarballPath) + require.NotEmpty(t, deploymentID) + + // Wait for DNS + time.Sleep(10 * time.Second) + + // Get subdomain before deletion + deployment := e2e.GetDeployment(t, env, deploymentID) + subdomain, _ := deployment["subdomain"].(string) + if subdomain == "" { + subdomain = deploymentName + } + fqdn := fmt.Sprintf("%s.%s", subdomain, env.BaseDomain) + + // Verify DNS works before deletion + t.Run("DNS resolves before deletion", func(t *testing.T) { + nodeURL := extractNodeURLProd(t, deployment) + if nodeURL == "" { + t.Skip("No URL to test") + } + domain := extractDomainProd(nodeURL) + + req, _ := http.NewRequest("GET", fmt.Sprintf("http://%s:6001/", env.Config.Servers[0].IP), nil) + req.Host = domain + + resp, err := env.HTTPClient.Do(req) + if err == nil { + resp.Body.Close() + t.Logf("Pre-delete: status=%d", resp.StatusCode) + } + }) + + // Delete + e2e.DeleteDeployment(t, env, deploymentID) + time.Sleep(10 * time.Second) + + t.Run("DNS records removed after deletion", func(t *testing.T) { + ips, err := net.LookupHost(fqdn) + if err != nil { + t.Logf("DNS lookup failed (expected): %v", err) + return // Good — no records + } + + // If we still get IPs, they might be cached. Log and warn. + if len(ips) > 0 { + t.Logf("WARNING: DNS still returns %d IPs after deletion (may be cached): %v", len(ips), ips) + } + }) +} + +// TestDNS_CustomSubdomain verifies that deploying with a custom subdomain +// creates DNS records using the custom name. +func TestDNS_CustomSubdomain(t *testing.T) { + e2e.SkipIfLocal(t) + + env, err := e2e.LoadTestEnv() + require.NoError(t, err) + + deploymentName := fmt.Sprintf("dns-custom-%d", time.Now().Unix()) + tarballPath := filepath.Join("../../testdata/apps/react-app") + + deploymentID := createDeploymentWithSubdomain(t, env, deploymentName, tarballPath) + require.NotEmpty(t, deploymentID) + + defer func() { + if !env.SkipCleanup { + e2e.DeleteDeployment(t, env, deploymentID) + } + }() + + time.Sleep(10 * time.Second) + + t.Run("Deployment has subdomain with random suffix", func(t *testing.T) { + deployment := e2e.GetDeployment(t, env, deploymentID) + subdomain, _ := deployment["subdomain"].(string) + require.NotEmpty(t, subdomain, "Deployment should have a subdomain") + t.Logf("Subdomain: %s", subdomain) + + // Verify the subdomain starts with the deployment name + assert.Contains(t, subdomain, deploymentName[:10], + "Subdomain should relate to deployment name") + }) +} + +// TestDNS_RedeployPreservesSubdomain verifies that updating a deployment +// does not change the subdomain/DNS. +func TestDNS_RedeployPreservesSubdomain(t *testing.T) { + e2e.SkipIfLocal(t) + + env, err := e2e.LoadTestEnv() + require.NoError(t, err) + + deploymentName := fmt.Sprintf("dns-preserve-%d", time.Now().Unix()) + tarballPath := filepath.Join("../../testdata/apps/react-app") + + deploymentID := e2e.CreateTestDeployment(t, env, deploymentName, tarballPath) + require.NotEmpty(t, deploymentID) + + defer func() { + if !env.SkipCleanup { + e2e.DeleteDeployment(t, env, deploymentID) + } + }() + + time.Sleep(5 * time.Second) + + // Get original subdomain + deployment := e2e.GetDeployment(t, env, deploymentID) + originalSubdomain, _ := deployment["subdomain"].(string) + originalURLs := deployment["urls"] + t.Logf("Original subdomain: %s, urls: %v", originalSubdomain, originalURLs) + + // Update + updateStaticDeploymentProd(t, env, deploymentName, tarballPath) + time.Sleep(5 * time.Second) + + // Verify subdomain unchanged + t.Run("Subdomain unchanged after update", func(t *testing.T) { + updated := e2e.GetDeployment(t, env, deploymentID) + updatedSubdomain, _ := updated["subdomain"].(string) + + assert.Equal(t, originalSubdomain, updatedSubdomain, + "Subdomain should not change after update") + t.Logf("After update: subdomain=%s", updatedSubdomain) + }) +} + +func createDeploymentWithSubdomain(t *testing.T, env *e2e.E2ETestEnv, name, tarballPath string) string { + t.Helper() + + var fileData []byte + info, err := os.Stat(tarballPath) + require.NoError(t, err) + if info.IsDir() { + fileData, err = exec.Command("tar", "-czf", "-", "-C", tarballPath, ".").Output() + require.NoError(t, err) + } else { + file, err := os.Open(tarballPath) + require.NoError(t, err) + defer file.Close() + fileData, _ = io.ReadAll(file) + } + + body := &bytes.Buffer{} + boundary := "----WebKitFormBoundary7MA4YWxkTrZu0gW" + + body.WriteString("--" + boundary + "\r\n") + body.WriteString("Content-Disposition: form-data; name=\"name\"\r\n\r\n") + body.WriteString(name + "\r\n") + + body.WriteString("--" + boundary + "\r\n") + body.WriteString("Content-Disposition: form-data; name=\"tarball\"; filename=\"app.tar.gz\"\r\n") + body.WriteString("Content-Type: application/gzip\r\n\r\n") + + body.Write(fileData) + body.WriteString("\r\n--" + boundary + "--\r\n") + + req, err := http.NewRequest("POST", env.GatewayURL+"/v1/deployments/static/upload", body) + require.NoError(t, err) + req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary) + req.Header.Set("Authorization", "Bearer "+env.APIKey) + + resp, err := env.HTTPClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("Upload failed: status=%d body=%s", resp.StatusCode, string(bodyBytes)) + } + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + if id, ok := result["deployment_id"].(string); ok { + return id + } + if id, ok := result["id"].(string); ok { + return id + } + t.Fatalf("No id in response: %+v", result) + return "" +} + +func updateStaticDeploymentProd(t *testing.T, env *e2e.E2ETestEnv, name, tarballPath string) { + t.Helper() + + var fileData []byte + info, err := os.Stat(tarballPath) + require.NoError(t, err) + if info.IsDir() { + fileData, err = exec.Command("tar", "-czf", "-", "-C", tarballPath, ".").Output() + require.NoError(t, err) + } else { + file, err := os.Open(tarballPath) + require.NoError(t, err) + defer file.Close() + fileData, _ = io.ReadAll(file) + } + + body := &bytes.Buffer{} + boundary := "----WebKitFormBoundary7MA4YWxkTrZu0gW" + + body.WriteString("--" + boundary + "\r\n") + body.WriteString("Content-Disposition: form-data; name=\"name\"\r\n\r\n") + body.WriteString(name + "\r\n") + + body.WriteString("--" + boundary + "\r\n") + body.WriteString("Content-Disposition: form-data; name=\"tarball\"; filename=\"app.tar.gz\"\r\n") + body.WriteString("Content-Type: application/gzip\r\n\r\n") + + body.Write(fileData) + body.WriteString("\r\n--" + boundary + "--\r\n") + + req, err := http.NewRequest("POST", env.GatewayURL+"/v1/deployments/static/update", body) + require.NoError(t, err) + req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary) + req.Header.Set("Authorization", "Bearer "+env.APIKey) + + resp, err := env.HTTPClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("Update failed: status=%d body=%s", resp.StatusCode, string(bodyBytes)) + } +} diff --git a/e2e/production/dns_resolution_test.go b/e2e/production/dns_resolution_test.go index b90d511..100924b 100644 --- a/e2e/production/dns_resolution_test.go +++ b/e2e/production/dns_resolution_test.go @@ -24,7 +24,7 @@ func TestDNS_DeploymentResolution(t *testing.T) { require.NoError(t, err, "Failed to load test environment") deploymentName := fmt.Sprintf("dns-test-%d", time.Now().Unix()) - tarballPath := filepath.Join("../../testdata/tarballs/react-vite.tar.gz") + tarballPath := filepath.Join("../../testdata/apps/react-app") deploymentID := e2e.CreateTestDeployment(t, env, deploymentName, tarballPath) defer func() { diff --git a/e2e/production/failover_test.go b/e2e/production/failover_test.go new file mode 100644 index 0000000..fc951b1 --- /dev/null +++ b/e2e/production/failover_test.go @@ -0,0 +1,281 @@ +//go:build e2e && production + +package production + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/DeBrosOfficial/network/e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestFailover_HomeNodeDown verifies that when the home node's deployment process +// is down, requests still succeed via the replica node. +func TestFailover_HomeNodeDown(t *testing.T) { + e2e.SkipIfLocal(t) + + env, err := e2e.LoadTestEnv() + require.NoError(t, err) + + if len(env.Config.Servers) < 2 { + t.Skip("Failover testing requires at least 2 servers") + } + + // Deploy a Node.js backend so we have a process to stop + deploymentName := fmt.Sprintf("failover-test-%d", time.Now().Unix()) + tarballPath := filepath.Join("../../testdata/apps/node-api") + + deploymentID := createNodeJSDeploymentProd(t, env, deploymentName, tarballPath) + require.NotEmpty(t, deploymentID) + + defer func() { + if !env.SkipCleanup { + e2e.DeleteDeployment(t, env, deploymentID) + } + }() + + // Wait for deployment and replica + healthy := e2e.WaitForHealthy(t, env, deploymentID, 90*time.Second) + require.True(t, healthy, "Deployment should become healthy") + time.Sleep(20 * time.Second) // Wait for async replica setup + + deployment := e2e.GetDeployment(t, env, deploymentID) + nodeURL := extractNodeURLProd(t, deployment) + require.NotEmpty(t, nodeURL) + domain := extractDomainProd(nodeURL) + + t.Run("All nodes serve before failover", func(t *testing.T) { + for _, server := range env.Config.Servers { + gatewayURL := fmt.Sprintf("http://%s:6001", server.IP) + req, _ := http.NewRequest("GET", gatewayURL+"/health", nil) + req.Host = domain + + resp, err := env.HTTPClient.Do(req) + if err != nil { + t.Logf("%s: unreachable: %v", server.Name, err) + continue + } + resp.Body.Close() + t.Logf("%s: status=%d", server.Name, resp.StatusCode) + } + }) + + t.Run("Requests succeed via non-home nodes", func(t *testing.T) { + // Find home node + homeNodeID, _ := deployment["home_node_id"].(string) + t.Logf("Home node: %s", homeNodeID) + + // Send requests to each non-home server + // Even without stopping the home node, we verify all nodes can serve + successCount := 0 + for _, server := range env.Config.Servers { + gatewayURL := fmt.Sprintf("http://%s:6001", server.IP) + + req, _ := http.NewRequest("GET", gatewayURL+"/health", nil) + req.Host = domain + + resp, err := env.HTTPClient.Do(req) + if err != nil { + t.Logf("%s: failed: %v", server.Name, err) + continue + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode == http.StatusOK { + successCount++ + t.Logf("%s: OK - %s", server.Name, string(body)) + } else { + t.Logf("%s: status=%d body=%s", server.Name, resp.StatusCode, string(body)) + } + } + + assert.GreaterOrEqual(t, successCount, 2, + "At least 2 nodes should serve the deployment (replica + home)") + }) +} + +// TestFailover_5xxRetry verifies that if one node returns a gateway error, +// the middleware retries on the next replica. +func TestFailover_5xxRetry(t *testing.T) { + e2e.SkipIfLocal(t) + + env, err := e2e.LoadTestEnv() + require.NoError(t, err) + + if len(env.Config.Servers) < 2 { + t.Skip("Requires at least 2 servers") + } + + // Deploy a static app (always works via IPFS, no process to crash) + deploymentName := fmt.Sprintf("retry-test-%d", time.Now().Unix()) + tarballPath := filepath.Join("../../testdata/apps/react-app") + + deploymentID := e2e.CreateTestDeployment(t, env, deploymentName, tarballPath) + require.NotEmpty(t, deploymentID) + + defer func() { + if !env.SkipCleanup { + e2e.DeleteDeployment(t, env, deploymentID) + } + }() + + time.Sleep(10 * time.Second) + + deployment := e2e.GetDeployment(t, env, deploymentID) + nodeURL := extractNodeURLProd(t, deployment) + if nodeURL == "" { + t.Skip("No node URL") + } + domain := extractDomainProd(nodeURL) + + t.Run("All nodes serve successfully", func(t *testing.T) { + for _, server := range env.Config.Servers { + gatewayURL := fmt.Sprintf("http://%s:6001", server.IP) + req, _ := http.NewRequest("GET", gatewayURL+"/", nil) + req.Host = domain + + resp, err := env.HTTPClient.Do(req) + require.NoError(t, err, "Request to %s should not error", server.Name) + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + assert.Equal(t, http.StatusOK, resp.StatusCode, + "Request via %s should return 200 (got %d: %s)", server.Name, resp.StatusCode, string(body)) + } + }) +} + +// TestFailover_CrossNodeProxyTimeout verifies that cross-node proxy fails fast +// (within a reasonable timeout) rather than hanging. +func TestFailover_CrossNodeProxyTimeout(t *testing.T) { + e2e.SkipIfLocal(t) + + env, err := e2e.LoadTestEnv() + require.NoError(t, err) + + if len(env.Config.Servers) < 2 { + t.Skip("Requires at least 2 servers") + } + + // Make a request to a non-existent deployment — should fail fast + domain := fmt.Sprintf("nonexistent-%d.%s", time.Now().Unix(), env.BaseDomain) + + start := time.Now() + + req, _ := http.NewRequest("GET", fmt.Sprintf("http://%s:6001/", env.Config.Servers[0].IP), nil) + req.Host = domain + + resp, err := env.HTTPClient.Do(req) + elapsed := time.Since(start) + + if err != nil { + t.Logf("Request failed in %v: %v", elapsed, err) + } else { + resp.Body.Close() + t.Logf("Got status %d in %v", resp.StatusCode, elapsed) + } + + // Should respond within 15 seconds (our proxy timeout is 5s) + assert.Less(t, elapsed.Seconds(), 15.0, + "Request to non-existent deployment should fail fast, took %v", elapsed) +} + +func createNodeJSDeploymentProd(t *testing.T, env *e2e.E2ETestEnv, name, tarballPath string) string { + t.Helper() + + var fileData []byte + + info, err := os.Stat(tarballPath) + require.NoError(t, err, "Failed to stat: %s", tarballPath) + + if info.IsDir() { + tarData, err := exec.Command("tar", "-czf", "-", "-C", tarballPath, ".").Output() + require.NoError(t, err, "Failed to create tarball from %s", tarballPath) + fileData = tarData + } else { + file, err := os.Open(tarballPath) + require.NoError(t, err, "Failed to open tarball: %s", tarballPath) + defer file.Close() + fileData, _ = io.ReadAll(file) + } + + body := &bytes.Buffer{} + boundary := "----WebKitFormBoundary7MA4YWxkTrZu0gW" + + body.WriteString("--" + boundary + "\r\n") + body.WriteString("Content-Disposition: form-data; name=\"name\"\r\n\r\n") + body.WriteString(name + "\r\n") + + body.WriteString("--" + boundary + "\r\n") + body.WriteString("Content-Disposition: form-data; name=\"tarball\"; filename=\"app.tar.gz\"\r\n") + body.WriteString("Content-Type: application/gzip\r\n\r\n") + + body.Write(fileData) + body.WriteString("\r\n--" + boundary + "--\r\n") + + req, err := http.NewRequest("POST", env.GatewayURL+"/v1/deployments/nodejs/upload", body) + require.NoError(t, err) + + req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary) + req.Header.Set("Authorization", "Bearer "+env.APIKey) + + resp, err := env.HTTPClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("Deployment upload failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var result map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + + if id, ok := result["deployment_id"].(string); ok { + return id + } + if id, ok := result["id"].(string); ok { + return id + } + t.Fatalf("Deployment response missing id: %+v", result) + return "" +} + +func extractNodeURLProd(t *testing.T, deployment map[string]interface{}) string { + t.Helper() + if urls, ok := deployment["urls"].([]interface{}); ok && len(urls) > 0 { + if url, ok := urls[0].(string); ok { + return url + } + } + if urls, ok := deployment["urls"].(map[string]interface{}); ok { + if url, ok := urls["node"].(string); ok { + return url + } + } + return "" +} + +func extractDomainProd(url string) string { + domain := url + if len(url) > 8 && url[:8] == "https://" { + domain = url[8:] + } else if len(url) > 7 && url[:7] == "http://" { + domain = url[7:] + } + if len(domain) > 0 && domain[len(domain)-1] == '/' { + domain = domain[:len(domain)-1] + } + return domain +} diff --git a/e2e/production/middleware_test.go b/e2e/production/middleware_test.go new file mode 100644 index 0000000..4bc151d --- /dev/null +++ b/e2e/production/middleware_test.go @@ -0,0 +1,101 @@ +//go:build e2e && production + +package production + +import ( + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/DeBrosOfficial/network/e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestMiddleware_NonExistentDeployment verifies that requests to a non-existent +// deployment return 404 (not 502 or hang). +func TestMiddleware_NonExistentDeployment(t *testing.T) { + e2e.SkipIfLocal(t) + + env, err := e2e.LoadTestEnv() + require.NoError(t, err) + + domain := fmt.Sprintf("does-not-exist-%d.%s", time.Now().Unix(), env.BaseDomain) + + req, _ := http.NewRequest("GET", fmt.Sprintf("http://%s:6001/", env.Config.Servers[0].IP), nil) + req.Host = domain + + start := time.Now() + resp, err := env.HTTPClient.Do(req) + elapsed := time.Since(start) + + if err != nil { + t.Logf("Request failed in %v: %v", elapsed, err) + // Connection refused or timeout is acceptable + assert.Less(t, elapsed.Seconds(), 15.0, "Should fail fast") + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + t.Logf("Status: %d, elapsed: %v, body: %s", resp.StatusCode, elapsed, string(body)) + + // Should be 404 or 502, not 200 + assert.NotEqual(t, http.StatusOK, resp.StatusCode, + "Non-existent deployment should not return 200") + assert.Less(t, elapsed.Seconds(), 15.0, "Should respond fast") +} + +// TestMiddleware_InternalAPIAuthRejection verifies that internal replica API +// endpoints reject requests without the proper internal auth header. +func TestMiddleware_InternalAPIAuthRejection(t *testing.T) { + e2e.SkipIfLocal(t) + + env, err := e2e.LoadTestEnv() + require.NoError(t, err) + + serverIP := env.Config.Servers[0].IP + + t.Run("No auth header rejected", func(t *testing.T) { + req, _ := http.NewRequest("POST", + fmt.Sprintf("http://%s:6001/v1/internal/deployments/replica/setup", serverIP), nil) + + resp, err := env.HTTPClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + // Should be rejected (401 or 403) + assert.True(t, resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden, + "Internal API without auth should be rejected (got %d)", resp.StatusCode) + }) + + t.Run("Wrong auth header rejected", func(t *testing.T) { + req, _ := http.NewRequest("POST", + fmt.Sprintf("http://%s:6001/v1/internal/deployments/replica/setup", serverIP), nil) + req.Header.Set("X-Orama-Internal-Auth", "wrong-token") + + resp, err := env.HTTPClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.True(t, resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusBadRequest, + "Internal API with wrong auth should be rejected (got %d)", resp.StatusCode) + }) + + t.Run("Regular API key does not grant internal access", func(t *testing.T) { + req, _ := http.NewRequest("POST", + fmt.Sprintf("http://%s:6001/v1/internal/deployments/replica/setup", serverIP), nil) + req.Header.Set("Authorization", "Bearer "+env.APIKey) + + resp, err := env.HTTPClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + // The request may pass auth but fail on bad body — 400 is acceptable + // But it should NOT succeed with 200 + assert.NotEqual(t, http.StatusOK, resp.StatusCode, + "Regular API key should not fully authenticate internal endpoints") + }) +} diff --git a/e2e/shared/auth_extended_test.go b/e2e/shared/auth_extended_test.go new file mode 100644 index 0000000..846784f --- /dev/null +++ b/e2e/shared/auth_extended_test.go @@ -0,0 +1,148 @@ +//go:build e2e + +package shared + +import ( + "net/http" + "testing" + "time" + + "github.com/DeBrosOfficial/network/e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAuth_ExpiredOrInvalidJWT verifies that an expired/invalid JWT token is rejected. +func TestAuth_ExpiredOrInvalidJWT(t *testing.T) { + e2e.SkipIfMissingGateway(t) + + gatewayURL := e2e.GetGatewayURL() + + // Craft an obviously invalid JWT + invalidJWT := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjoxfQ.invalid" + + req, err := http.NewRequest("GET", gatewayURL+"/v1/deployments/list", nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+invalidJWT) + + client := e2e.NewHTTPClient(10 * time.Second) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, + "Invalid JWT should return 401") +} + +// TestAuth_EmptyAPIKey verifies that an empty API key is rejected. +func TestAuth_EmptyAPIKey(t *testing.T) { + e2e.SkipIfMissingGateway(t) + + gatewayURL := e2e.GetGatewayURL() + + req, err := http.NewRequest("GET", gatewayURL+"/v1/deployments/list", nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer ") + + client := e2e.NewHTTPClient(10 * time.Second) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, + "Empty API key should return 401") +} + +// TestAuth_SQLInjectionInAPIKey verifies that SQL injection in the API key +// does not bypass authentication. +func TestAuth_SQLInjectionInAPIKey(t *testing.T) { + e2e.SkipIfMissingGateway(t) + + gatewayURL := e2e.GetGatewayURL() + + injectionAttempts := []string{ + "' OR '1'='1", + "'; DROP TABLE api_keys; --", + "\" OR \"1\"=\"1", + "admin'--", + } + + for _, attempt := range injectionAttempts { + t.Run(attempt, func(t *testing.T) { + req, _ := http.NewRequest("GET", gatewayURL+"/v1/deployments/list", nil) + req.Header.Set("Authorization", "Bearer "+attempt) + + client := e2e.NewHTTPClient(10 * time.Second) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, + "SQL injection attempt should be rejected") + }) + } +} + +// TestAuth_NamespaceScopedAccess verifies that an API key for one namespace +// cannot access another namespace's deployments. +func TestAuth_NamespaceScopedAccess(t *testing.T) { + // Create two environments with different namespaces + env1, err := e2e.LoadTestEnvWithNamespace("auth-test-ns1") + if err != nil { + t.Skip("Could not create namespace env1: " + err.Error()) + } + + env2, err := e2e.LoadTestEnvWithNamespace("auth-test-ns2") + if err != nil { + t.Skip("Could not create namespace env2: " + err.Error()) + } + + t.Run("Namespace 1 key cannot list namespace 2 deployments", func(t *testing.T) { + // Use env1's API key to query env2's gateway + // The namespace should be scoped to the API key + req, _ := http.NewRequest("GET", env2.GatewayURL+"/v1/deployments/list", nil) + req.Header.Set("Authorization", "Bearer "+env1.APIKey) + req.Header.Set("X-Namespace", "auth-test-ns2") + + resp, err := env1.HTTPClient.Do(req) + if err != nil { + t.Skip("Gateway unreachable") + } + defer resp.Body.Close() + + // The API should either reject (403) or return only ns1's deployments + t.Logf("Cross-namespace access returned: %d", resp.StatusCode) + + if resp.StatusCode == http.StatusOK { + t.Log("API returned 200 — namespace isolation may be enforced at data level") + } + }) +} + +// TestAuth_PublicEndpointsNoAuth verifies that health/status endpoints +// don't require authentication. +func TestAuth_PublicEndpointsNoAuth(t *testing.T) { + e2e.SkipIfMissingGateway(t) + + gatewayURL := e2e.GetGatewayURL() + client := e2e.NewHTTPClient(10 * time.Second) + + publicPaths := []string{ + "/v1/health", + "/v1/status", + } + + for _, path := range publicPaths { + t.Run(path, func(t *testing.T) { + req, _ := http.NewRequest("GET", gatewayURL+path, nil) + // No auth header + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode, + "%s should be accessible without auth", path) + }) + } +}