package e2e import ( "bytes" "context" "encoding/json" "fmt" "io" "net" "net/http" "os/exec" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var ( testAPIPort string testAPIBaseURL string testAPIBaseDir string ) // startRedisForAPI starts the Redis container for API tests func startRedisForAPI(t *testing.T, log *TestLogger) func() { t.Helper() composePath := getDockerComposePath(t) log.Info("Starting Redis container for API tests") cmd := exec.Command("docker-compose", "-f", composePath, "up", "-d") output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("Failed to start Redis container: %v\nOutput: %s", err, output) } // Wait for Redis to be ready waitForRedisAPI(t, log, testRedisPort, 15*time.Second) return func() { log.Info("Stopping Redis container") cmd := exec.Command("docker-compose", "-f", composePath, "down", "-v") _ = cmd.Run() } } // waitForRedisAPI waits for Redis to accept connections func waitForRedisAPI(t *testing.T, log *TestLogger, port string, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) log.Info("Waiting for Redis on port %s...", port) for time.Now().Before(deadline) { conn, err := net.DialTimeout("tcp", "localhost:"+port, time.Second) if err == nil { _ = conn.Close() log.Success("Redis is ready") return } time.Sleep(500 * time.Millisecond) } t.Fatalf("Redis not ready after %v", timeout) } // cleanAndSeedDatabase cleans and seeds the database using CLI func cleanAndSeedDatabase(t *testing.T, log *TestLogger) { t.Helper() binary := getBinaryPath(t) log.Info("Cleaning database...") cmd := exec.Command(binary, "--base-folder", testAPIBaseDir, "db", "clean", "--force") output, err := cmd.CombinedOutput() if err != nil { log.Warn("Database clean failed (may be empty): %s", string(output)) } log.Info("Seeding database with sample data...") cmd = exec.Command(binary, "--base-folder", testAPIBaseDir, "db", "seed") output, err = cmd.CombinedOutput() if err != nil { t.Fatalf("Failed to seed database: %v\nOutput: %s", err, output) } log.Success("Database seeded successfully") } // startAPIServer starts the API server for testing func startAPIServer(t *testing.T, log *TestLogger, ctx context.Context) (*exec.Cmd, func()) { t.Helper() binary := getBinaryPath(t) workflowPath := getTestdataPath(t) log.Info("Starting API server on port %s", testAPIPort) cmd := exec.CommandContext(ctx, binary, "serve", "--master", "--port", testAPIPort, "--redis-url", testRedisURL, "-A", // Disable auth for testing "--base-folder", testAPIBaseDir, "-F", workflowPath, ) err := cmd.Start() require.NoError(t, err, "Failed to start API server") // Wait for server to be ready waitForAPIServer(t, log, testAPIPort, 15*time.Second) return cmd, func() { log.Info("Stopping API server") if cmd.Process != nil { _ = cmd.Process.Kill() _ = cmd.Wait() } } } // waitForAPIServer waits for the API server to respond func waitForAPIServer(t *testing.T, log *TestLogger, port string, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) url := "http://localhost:" + port + "/health" log.Info("Waiting for API server at %s...", url) for time.Now().Before(deadline) { resp, err := http.Get(url) if err == nil { _ = resp.Body.Close() if resp.StatusCode == 200 { log.Success("API server is ready") return } } time.Sleep(500 * time.Millisecond) } t.Fatalf("API server not ready after %v", timeout) } // apiGet performs a GET request to the API func apiGet(t *testing.T, path string) *http.Response { t.Helper() resp, err := http.Get(testAPIBaseURL + path) require.NoError(t, err, "GET %s failed", path) return resp } // apiPost performs a POST request to the API func apiPost(t *testing.T, path string, body interface{}) *http.Response { t.Helper() var reader io.Reader if body != nil { jsonBody, err := json.Marshal(body) require.NoError(t, err, "Failed to marshal request body") reader = bytes.NewReader(jsonBody) } resp, err := http.Post(testAPIBaseURL+path, "application/json", reader) require.NoError(t, err, "POST %s failed", path) return resp } // apiPut performs a PUT request to the API func apiPut(t *testing.T, path string, body interface{}) *http.Response { t.Helper() var reader io.Reader if body != nil { jsonBody, err := json.Marshal(body) require.NoError(t, err, "Failed to marshal request body") reader = bytes.NewReader(jsonBody) } req, err := http.NewRequest(http.MethodPut, testAPIBaseURL+path, reader) require.NoError(t, err, "Failed to create PUT request") req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) require.NoError(t, err, "PUT %s failed", path) return resp } // apiDelete performs a DELETE request to the API func apiDelete(t *testing.T, path string) *http.Response { t.Helper() req, err := http.NewRequest(http.MethodDelete, testAPIBaseURL+path, nil) require.NoError(t, err, "Failed to create DELETE request") resp, err := http.DefaultClient.Do(req) require.NoError(t, err, "DELETE %s failed", path) return resp } // parseJSONResponse parses the JSON response body func parseJSONResponse(t *testing.T, resp *http.Response) map[string]interface{} { t.Helper() defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) require.NoError(t, err, "Failed to read response body") var result map[string]interface{} err = json.Unmarshal(body, &result) require.NoError(t, err, "Failed to parse JSON response: %s", string(body)) return result } // TestAPI_AllEndpoints tests all API endpoints with a shared setup func TestAPI_AllEndpoints(t *testing.T) { if testing.Short() { t.Skip("skipping API e2e test in short mode") } log := NewTestLogger(t) log.Step("Running API E2E tests") testAPIPort = getFreePort(t) testAPIBaseURL = "http://localhost:" + testAPIPort testAPIBaseDir = t.TempDir() // Step 1: Start Redis log.Step("Step 1: Starting Redis") cleanupRedis := startRedisForAPI(t, log) defer cleanupRedis() // Step 2: Clean and seed database log.Step("Step 2: Cleaning and seeding database") cleanAndSeedDatabase(t, log) // Step 3: Start API server log.Step("Step 3: Starting API server") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() _, cleanupServer := startAPIServer(t, log, ctx) defer cleanupServer() // Step 4: Run subtests log.Step("Step 4: Running API endpoint tests") t.Run("Health", func(t *testing.T) { testHealthEndpoints(t, log) }) t.Run("Info", func(t *testing.T) { testInfoEndpoints(t, log) }) t.Run("Workflows", func(t *testing.T) { testWorkflowEndpoints(t, log) }) t.Run("Workspaces", func(t *testing.T) { testWorkspaceEndpoints(t, log) }) t.Run("Assets", func(t *testing.T) { testAssetEndpoints(t, log) }) t.Run("Stats", func(t *testing.T) { testStatsEndpoint(t, log) }) t.Run("Schedules", func(t *testing.T) { testScheduleEndpoints(t, log) }) t.Run("Registry", func(t *testing.T) { testRegistryEndpoint(t, log) }) t.Run("Distributed", func(t *testing.T) { testDistributedEndpoints(t, log) }) t.Run("Vulnerabilities", func(t *testing.T) { testVulnerabilityEndpoints(t, log) }) t.Run("Runs", func(t *testing.T) { testRunEndpoints(t, log) }) t.Run("EventLogs", func(t *testing.T) { testEventLogEndpoints(t, log) }) t.Run("Functions", func(t *testing.T) { testFunctionEndpoints(t, log) }) t.Run("Settings", func(t *testing.T) { testSettingsEndpoints(t, log) }) t.Run("WorkflowExtras", func(t *testing.T) { testWorkflowExtras(t, log) }) log.Success("All API E2E tests passed!") } func getFreePort(t *testing.T) string { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err, "Failed to get free port") defer func() { _ = ln.Close() }() _, port, err := net.SplitHostPort(ln.Addr().String()) require.NoError(t, err, "Failed to parse free port") return port } // testHealthEndpoints tests health check endpoints func testHealthEndpoints(t *testing.T, log *TestLogger) { log.Info("Testing health endpoints") // GET /health resp := apiGet(t, "/health") assert.Equal(t, 200, resp.StatusCode, "GET /health should return 200") body := parseJSONResponse(t, resp) assert.Equal(t, "ok", body["status"], "Health status should be 'ok'") // GET /health/ready resp = apiGet(t, "/health/ready") assert.Equal(t, 200, resp.StatusCode, "GET /health/ready should return 200") log.Success("Health endpoints OK") } // testInfoEndpoints tests server info endpoints func testInfoEndpoints(t *testing.T, log *TestLogger) { log.Info("Testing info endpoints") // Note: GET / serves the embedded UI (HTML), not JSON API // We only test the /server-info JSON endpoint here // GET /server-info resp := apiGet(t, "/server-info") assert.Equal(t, 200, resp.StatusCode, "GET /server-info should return 200") body := parseJSONResponse(t, resp) assert.Contains(t, body, "version", "Server-info should contain version") log.Success("Info endpoints OK") } // testWorkflowEndpoints tests workflow endpoints func testWorkflowEndpoints(t *testing.T, log *TestLogger) { log.Info("Testing workflow endpoints") // GET /osm/api/workflows (DB-based listing returns pagination) resp := apiGet(t, "/osm/api/workflows") assert.Equal(t, 200, resp.StatusCode, "GET /osm/api/workflows should return 200") body := parseJSONResponse(t, resp) assert.Contains(t, body, "data", "Should contain data array") // Note: DB-based listing returns "pagination" with "total", not "count" assert.Contains(t, body, "pagination", "Should contain pagination") // GET /osm/api/workflows/test-bash?json=true (need json=true to get JSON, otherwise returns YAML) resp = apiGet(t, "/osm/api/workflows/test-bash?json=true") // May return 200 (workflow found) or 404 (workflow not found) assert.True(t, resp.StatusCode == 200 || resp.StatusCode == 404, "GET /osm/api/workflows/test-bash should return 200 or 404") if resp.StatusCode == 200 { body = parseJSONResponse(t, resp) assert.Contains(t, body, "name", "Should contain workflow name") } log.Success("Workflow endpoints OK") } // testWorkspaceEndpoints tests workspace endpoints func testWorkspaceEndpoints(t *testing.T, log *TestLogger) { log.Info("Testing workspace endpoints") // GET /osm/api/workspaces (seeded data) resp := apiGet(t, "/osm/api/workspaces") assert.Equal(t, 200, resp.StatusCode, "GET /osm/api/workspaces should return 200") body := parseJSONResponse(t, resp) assert.Contains(t, body, "data", "Should contain data array") // Seeded data should have workspaces data, ok := body["data"].([]interface{}) assert.True(t, ok, "Data should be an array") assert.NotEmpty(t, data, "Should have seeded workspaces") log.Success("Workspace endpoints OK") } // testAssetEndpoints tests asset endpoints func testAssetEndpoints(t *testing.T, log *TestLogger) { log.Info("Testing asset endpoints") // GET /osm/api/assets (seeded data) resp := apiGet(t, "/osm/api/assets") assert.Equal(t, 200, resp.StatusCode, "GET /osm/api/assets should return 200") body := parseJSONResponse(t, resp) assert.Contains(t, body, "data", "Should contain data array") // Seeded data should have 10 assets data, ok := body["data"].([]interface{}) assert.True(t, ok, "Data should be an array") assert.NotEmpty(t, data, "Should have seeded assets") log.Success("Asset endpoints OK") } // testStatsEndpoint tests stats endpoint func testStatsEndpoint(t *testing.T, log *TestLogger) { log.Info("Testing stats endpoint") // GET /osm/api/stats resp := apiGet(t, "/osm/api/stats") assert.Equal(t, 200, resp.StatusCode, "GET /osm/api/stats should return 200") body := parseJSONResponse(t, resp) assert.Contains(t, body, "workflows", "Should contain workflows count") assert.Contains(t, body, "assets", "Should contain assets count") log.Success("Stats endpoint OK") } // testScheduleEndpoints tests schedule CRUD endpoints func testScheduleEndpoints(t *testing.T, log *TestLogger) { log.Info("Testing schedule endpoints") // GET /osm/api/schedules (seeded data) resp := apiGet(t, "/osm/api/schedules") assert.Equal(t, 200, resp.StatusCode, "GET /osm/api/schedules should return 200") // POST /osm/api/schedules - Create new schedule schedule := map[string]interface{}{ "name": "test-api-schedule", "workflow_name": "test-bash", "workflow_kind": "module", "target": "api-test.com", "schedule": "0 0 * * *", "enabled": true, } resp = apiPost(t, "/osm/api/schedules", schedule) assert.Equal(t, 201, resp.StatusCode, "POST /osm/api/schedules should return 201") body := parseJSONResponse(t, resp) // Schedule ID is in the "data" object returned by the API data, ok := body["data"].(map[string]interface{}) require.True(t, ok, "Response should contain data object") scheduleID, ok := data["id"].(string) require.True(t, ok, "Data should contain schedule id (lowercase)") require.NotEmpty(t, scheduleID, "Schedule ID should not be empty") // GET /osm/api/schedules/:id resp = apiGet(t, "/osm/api/schedules/"+scheduleID) assert.Equal(t, 200, resp.StatusCode, "GET /osm/api/schedules/:id should return 200") // PUT /osm/api/schedules/:id update := map[string]interface{}{"name": "updated-api-schedule"} resp = apiPut(t, "/osm/api/schedules/"+scheduleID, update) assert.Equal(t, 200, resp.StatusCode, "PUT /osm/api/schedules/:id should return 200") // POST /osm/api/schedules/:id/disable resp = apiPost(t, "/osm/api/schedules/"+scheduleID+"/disable", nil) assert.Equal(t, 200, resp.StatusCode, "POST /osm/api/schedules/:id/disable should return 200") // POST /osm/api/schedules/:id/enable resp = apiPost(t, "/osm/api/schedules/"+scheduleID+"/enable", nil) assert.Equal(t, 200, resp.StatusCode, "POST /osm/api/schedules/:id/enable should return 200") // DELETE /osm/api/schedules/:id resp = apiDelete(t, "/osm/api/schedules/"+scheduleID) assert.Equal(t, 200, resp.StatusCode, "DELETE /osm/api/schedules/:id should return 200") log.Success("Schedule endpoints OK") } // testRegistryEndpoint tests metadata registry endpoint func testRegistryEndpoint(t *testing.T, log *TestLogger) { log.Info("Testing registry endpoint") resp := apiGet(t, "/osm/api/registry-info") assert.Equal(t, 200, resp.StatusCode, "GET /osm/api/registry-info should return 200") body := parseJSONResponse(t, resp) assert.Contains(t, body, "registry_url", "Should contain registry_url") assert.Contains(t, body, "binaries", "Should contain binaries") log.Success("Registry endpoint OK") } // testDistributedEndpoints tests distributed mode endpoints func testDistributedEndpoints(t *testing.T, log *TestLogger) { log.Info("Testing distributed endpoints") // GET /osm/api/workers (master mode enabled) resp := apiGet(t, "/osm/api/workers") assert.Equal(t, 200, resp.StatusCode, "GET /osm/api/workers should return 200") body := parseJSONResponse(t, resp) assert.Contains(t, body, "count", "Should contain worker count") // GET /osm/api/tasks resp = apiGet(t, "/osm/api/tasks") assert.Equal(t, 200, resp.StatusCode, "GET /osm/api/tasks should return 200") body = parseJSONResponse(t, resp) assert.Contains(t, body, "running", "Should contain running tasks") assert.Contains(t, body, "completed", "Should contain completed tasks") log.Success("Distributed endpoints OK") } // testVulnerabilityEndpoints tests vulnerability CRUD endpoints func testVulnerabilityEndpoints(t *testing.T, log *TestLogger) { log.Info("Testing vulnerability endpoints") // GET /osm/api/vulnerabilities (seeded data) resp := apiGet(t, "/osm/api/vulnerabilities") assert.Equal(t, 200, resp.StatusCode, "GET /osm/api/vulnerabilities should return 200") body := parseJSONResponse(t, resp) assert.Contains(t, body, "data", "Should contain data array") assert.Contains(t, body, "pagination", "Should contain pagination") data, ok := body["data"].([]interface{}) assert.True(t, ok, "Data should be an array") assert.NotEmpty(t, data, "Should have seeded vulnerabilities") // GET /osm/api/vulnerabilities/summary resp = apiGet(t, "/osm/api/vulnerabilities/summary") assert.Equal(t, 200, resp.StatusCode, "GET /osm/api/vulnerabilities/summary should return 200") body = parseJSONResponse(t, resp) assert.Contains(t, body, "data", "Should contain data") summaryData, ok := body["data"].(map[string]interface{}) assert.True(t, ok, "Data should be an object") assert.Contains(t, summaryData, "by_severity", "Should contain severity breakdown") assert.Contains(t, summaryData, "total", "Should contain total count") // POST /osm/api/vulnerabilities - Create new vulnerability vuln := map[string]interface{}{ "workspace": "test-workspace", "vuln_title": "Test XSS Vulnerability", "vuln_info": "XSS in search parameter", "vuln_desc": "A reflected XSS vulnerability was found", "severity": "high", "asset_type": "endpoint", "asset_value": "test.example.com", "tags": []string{"xss", "test"}, } resp = apiPost(t, "/osm/api/vulnerabilities", vuln) assert.Equal(t, 201, resp.StatusCode, "POST /osm/api/vulnerabilities should return 201") body = parseJSONResponse(t, resp) assert.Contains(t, body, "data", "Should contain created data") createdData, ok := body["data"].(map[string]interface{}) require.True(t, ok, "Data should be an object") vulnID, ok := createdData["id"].(float64) require.True(t, ok, "Should have numeric id") require.NotZero(t, vulnID, "Vulnerability ID should not be zero") // GET /osm/api/vulnerabilities/:id vulnIDStr := fmt.Sprintf("%.0f", vulnID) resp = apiGet(t, "/osm/api/vulnerabilities/"+vulnIDStr) assert.Equal(t, 200, resp.StatusCode, "GET /osm/api/vulnerabilities/:id should return 200") body = parseJSONResponse(t, resp) assert.Contains(t, body, "data", "Should contain data") // DELETE /osm/api/vulnerabilities/:id resp = apiDelete(t, "/osm/api/vulnerabilities/"+vulnIDStr) assert.Equal(t, 200, resp.StatusCode, "DELETE /osm/api/vulnerabilities/:id should return 200") // Verify deletion resp = apiGet(t, "/osm/api/vulnerabilities/"+vulnIDStr) assert.Equal(t, 404, resp.StatusCode, "GET deleted vulnerability should return 404") log.Success("Vulnerability endpoints OK") } // testRunEndpoints tests run management endpoints func testRunEndpoints(t *testing.T, log *TestLogger) { log.Info("Testing run endpoints") // GET /osm/api/runs // Note: Current implementation is a stub that returns empty data resp := apiGet(t, "/osm/api/runs") assert.Equal(t, 200, resp.StatusCode, "GET /osm/api/runs should return 200") body := parseJSONResponse(t, resp) assert.Contains(t, body, "data", "Should contain data array") data, ok := body["data"].([]interface{}) assert.True(t, ok, "Data should be an array") // Use a test run ID for endpoint testing (handlers are stubs) testRunID := "test-run-123" // If we have seeded runs, use the first one if len(data) > 0 { if firstRun, ok := data[0].(map[string]interface{}); ok { if id, ok := firstRun["id"].(string); ok { testRunID = id } } } // GET /osm/api/runs/:id resp = apiGet(t, "/osm/api/runs/"+testRunID) assert.Equal(t, 200, resp.StatusCode, "GET /osm/api/runs/:id should return 200") // GET /osm/api/runs/:id/steps resp = apiGet(t, "/osm/api/runs/"+testRunID+"/steps") assert.Equal(t, 200, resp.StatusCode, "GET /osm/api/runs/:id/steps should return 200") body = parseJSONResponse(t, resp) assert.Contains(t, body, "data", "Should contain steps data") // GET /osm/api/runs/:id/artifacts resp = apiGet(t, "/osm/api/runs/"+testRunID+"/artifacts") assert.Equal(t, 200, resp.StatusCode, "GET /osm/api/runs/:id/artifacts should return 200") body = parseJSONResponse(t, resp) assert.Contains(t, body, "data", "Should contain artifacts data") // POST /osm/api/runs - Create new run (dry-run mode) newRun := map[string]interface{}{ "workflow_name": "test-bash", "target": "test-run.example.com", "dry_run": true, } resp = apiPost(t, "/osm/api/runs", newRun) // May return 201 (created) or 202 (accepted) or 400 (if workflow not found) // Accept 201, 202, or 400 as valid responses assert.True(t, resp.StatusCode == 201 || resp.StatusCode == 202 || resp.StatusCode == 400, "POST /osm/api/runs should return 201, 202, or 400 (workflow may not exist)") // DELETE /osm/api/runs/:id (cancel) - test with a test run ID resp = apiDelete(t, "/osm/api/runs/"+testRunID) // May return 200 (cancelled) or 400 (already completed/failed) assert.True(t, resp.StatusCode == 200 || resp.StatusCode == 400, "DELETE /osm/api/runs/:id should return 200 or 400") log.Success("Run endpoints OK") } // testEventLogEndpoints tests event log endpoints func testEventLogEndpoints(t *testing.T, log *TestLogger) { log.Info("Testing event log endpoints") // GET /osm/api/event-logs resp := apiGet(t, "/osm/api/event-logs") assert.Equal(t, 200, resp.StatusCode, "GET /osm/api/event-logs should return 200") body := parseJSONResponse(t, resp) assert.Contains(t, body, "data", "Should contain data array") assert.Contains(t, body, "pagination", "Should contain pagination") // GET /osm/api/event-logs?workspace=example.com (filter) resp = apiGet(t, "/osm/api/event-logs?workspace=example.com") assert.Equal(t, 200, resp.StatusCode, "GET /osm/api/event-logs with filter should return 200") body = parseJSONResponse(t, resp) assert.Contains(t, body, "data", "Should contain data array") // GET /osm/api/event-logs with limit resp = apiGet(t, "/osm/api/event-logs?limit=5") assert.Equal(t, 200, resp.StatusCode, "GET /osm/api/event-logs with limit should return 200") body = parseJSONResponse(t, resp) assert.Contains(t, body, "data", "Should contain data array") log.Success("Event log endpoints OK") } // testFunctionEndpoints tests function evaluation endpoints func testFunctionEndpoints(t *testing.T, log *TestLogger) { log.Info("Testing function endpoints") // GET /osm/api/functions/list resp := apiGet(t, "/osm/api/functions/list") assert.Equal(t, 200, resp.StatusCode, "GET /osm/api/functions/list should return 200") body := parseJSONResponse(t, resp) assert.Contains(t, body, "functions", "Should contain functions list") functions, ok := body["functions"].([]interface{}) assert.True(t, ok, "Functions should be an array") assert.NotEmpty(t, functions, "Should have available functions") // POST /osm/api/functions/eval - Evaluate a simple script // Note: Uses "script" field, not "expression" evalReq := map[string]interface{}{ "script": "1 + 1", } resp = apiPost(t, "/osm/api/functions/eval", evalReq) assert.Equal(t, 200, resp.StatusCode, "POST /osm/api/functions/eval should return 200") body = parseJSONResponse(t, resp) assert.Contains(t, body, "result", "Should contain result") // POST /osm/api/functions/eval - Evaluate with target variable evalReq = map[string]interface{}{ "script": "'hello ' + target", "target": "world", } resp = apiPost(t, "/osm/api/functions/eval", evalReq) assert.Equal(t, 200, resp.StatusCode, "POST /osm/api/functions/eval with target should return 200") body = parseJSONResponse(t, resp) assert.Contains(t, body, "result", "Should contain result") log.Success("Function endpoints OK") } // testSettingsEndpoints tests settings management endpoints func testSettingsEndpoints(t *testing.T, log *TestLogger) { log.Info("Testing settings endpoints") // GET /osm/api/settings/yaml - returns raw YAML text, not JSON resp := apiGet(t, "/osm/api/settings/yaml") assert.Equal(t, 200, resp.StatusCode, "GET /osm/api/settings/yaml should return 200") // Read raw body - endpoint returns YAML text, not JSON bodyBytes, err := io.ReadAll(resp.Body) require.NoError(t, err, "Should be able to read response body") yamlContent := string(bodyBytes) assert.NotEmpty(t, yamlContent, "Settings YAML should not be empty") // Verify it looks like YAML (contains common config keys) assert.Contains(t, yamlContent, "server:", "Should contain server config") log.Success("Settings endpoints OK") } // testWorkflowExtras tests additional workflow endpoints func testWorkflowExtras(t *testing.T, log *TestLogger) { log.Info("Testing workflow extra endpoints") // GET /osm/api/workflows/tags resp := apiGet(t, "/osm/api/workflows/tags") assert.Equal(t, 200, resp.StatusCode, "GET /osm/api/workflows/tags should return 200") body := parseJSONResponse(t, resp) assert.Contains(t, body, "tags", "Should contain tags array") _, ok := body["tags"].([]interface{}) assert.True(t, ok, "Tags should be an array") // May be empty if no workflows have tags, but should still be an array // POST /osm/api/workflows/refresh - Refresh workflow index resp = apiPost(t, "/osm/api/workflows/refresh", nil) assert.Equal(t, 200, resp.StatusCode, "POST /osm/api/workflows/refresh should return 200") body = parseJSONResponse(t, resp) assert.Contains(t, body, "message", "Should contain message") log.Success("Workflow extra endpoints OK") }