From 953f7f847a40b8b4b01cb00b357ea733b040044a Mon Sep 17 00:00:00 2001 From: O M Date: Mon, 12 Oct 2026 22:07:13 -0400 Subject: [PATCH] Explain a missing graph schema instead of a raw 500. The console showed a status code when om migrate had not created the graph tables. Co-authored-by: Cursor --- internal/api/server.go | 47 +++++++++++++++++++++++++++++------- internal/api/server_test.go | 48 +++++++++++++++++++++++++++++++++++++ internal/api/web/app.js | 31 +++++++++++++++++++----- 3 files changed, 111 insertions(+), 15 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index 257bd27..63edea6 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -14,11 +14,18 @@ import ( "strings" "time" + "github.com/jackc/pgx/v5/pgconn" + "github.com/OpenSourceOM/core/internal/export" "github.com/OpenSourceOM/core/internal/graph" "github.com/OpenSourceOM/core/internal/rules" ) +const ( + schemaNotReadyCode = "schema_not_ready" + schemaNotReadyMessage = "The graph database has not been initialized. Run om migrate, then refresh this page." +) + type Server struct { store *graph.Store health healthChecker @@ -112,7 +119,7 @@ func (s *Server) handleIngest(w http.ResponseWriter, r *http.Request) { } if err := s.store.UpsertBatch(r.Context(), batch); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + writeStoreError(w, err) return } @@ -125,7 +132,7 @@ func (s *Server) handleIngest(w http.ResponseWriter, r *http.Request) { func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) { stats, err := s.store.Stats(r.Context()) if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + writeStoreError(w, err) return } writeJSON(w, http.StatusOK, stats) @@ -166,7 +173,7 @@ func (s *Server) handleRulesRun(w http.ResponseWriter, r *http.Request) { result, err = s.rules.Run(r.Context(), ruleID) } if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeFailure(w, err, http.StatusBadRequest) return } writeJSON(w, http.StatusOK, result) @@ -182,14 +189,14 @@ func (s *Server) handleBlastRadius(w http.ResponseWriter, r *http.Request) { } node, err := s.store.FindIdentityByName(r.Context(), name) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeFailure(w, err, http.StatusBadRequest) return } identityID = node.ID } result, err := s.store.BlastRadius(r.Context(), identityID, 6) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeFailure(w, err, http.StatusBadRequest) return } writeJSON(w, http.StatusOK, result) @@ -204,7 +211,7 @@ func (s *Server) handleExportSlack(w http.ResponseWriter, r *http.Request) { records, err := export.LoadFindingRecords(r.Context(), s.store) if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + writeStoreError(w, err) return } @@ -302,13 +309,35 @@ func parsePageLimit(raw string, fallback, max int) (int, error) { } func writeListError(w http.ResponseWriter, err error) { - status := http.StatusInternalServerError if errors.Is(err, graph.ErrInvalidCursor) { - status = http.StatusBadRequest + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeStoreError(w, err) +} + +func writeStoreError(w http.ResponseWriter, err error) { + writeFailure(w, err, http.StatusInternalServerError) +} + +func writeFailure(w http.ResponseWriter, err error, status int) { + if isSchemaNotReady(err) { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": schemaNotReadyMessage, + "code": schemaNotReadyCode, + }) + return } writeJSON(w, status, map[string]string{"error": err.Error()}) } +// isSchemaNotReady reports a missing graph table, which is what Postgres +// returns when om migrate has not been applied. +func isSchemaNotReady(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "42P01" +} + func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) { name := r.URL.Query().Get("name") if name == "" { @@ -318,7 +347,7 @@ func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) { result, err := s.querier.Run(r.Context(), name) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeFailure(w, err, http.StatusBadRequest) return } writeJSON(w, http.StatusOK, result) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 21c9c95..a719916 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -4,10 +4,13 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "strings" "testing" + + "github.com/jackc/pgx/v5/pgconn" ) type fakeHealthChecker struct { @@ -128,6 +131,51 @@ func TestAPISecretUnsetLeavesRoutesOpen(t *testing.T) { } } +func TestWriteStoreErrorSchemaNotReady(t *testing.T) { + err := fmt.Errorf("count nodes: %w", &pgconn.PgError{ + Code: "42P01", + Message: `relation "nodes" does not exist`, + }) + recorder := httptest.NewRecorder() + writeStoreError(recorder, err) + + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusServiceUnavailable) + } + var body map[string]string + if decodeErr := json.NewDecoder(recorder.Body).Decode(&body); decodeErr != nil { + t.Fatalf("decode response: %v", decodeErr) + } + if body["code"] != schemaNotReadyCode { + t.Fatalf("code = %q, want %q", body["code"], schemaNotReadyCode) + } + if body["error"] != schemaNotReadyMessage { + t.Fatalf("error = %q, want %q", body["error"], schemaNotReadyMessage) + } + if strings.Contains(body["error"], "SQLSTATE") || strings.Contains(body["error"], "relation") { + t.Fatalf("schema error leaked database details: %q", body["error"]) + } +} + +func TestWriteStoreErrorKeepsOtherFailures(t *testing.T) { + recorder := httptest.NewRecorder() + writeStoreError(recorder, errors.New("connection reset")) + + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusInternalServerError) + } + var body map[string]string + if err := json.NewDecoder(recorder.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body["error"] != "connection reset" { + t.Fatalf("error = %q", body["error"]) + } + if _, ok := body["code"]; ok { + t.Fatal("unrelated store errors must not use the schema-not-ready code") + } +} + func TestParsePageLimit(t *testing.T) { const max = 500 tests := []struct { diff --git a/internal/api/web/app.js b/internal/api/web/app.js index a5ca562..36a9c3c 100644 --- a/internal/api/web/app.js +++ b/internal/api/web/app.js @@ -31,9 +31,13 @@ async function runRules() { btn.textContent = "Running rules…"; try { const res = await fetch("/v1/rules/run", { method: "POST", headers: apiHeaders() }); - if (!res.ok) throw new Error(`rules run returned ${res.status}`); + if (!res.ok) throw await errorFromResponse(res, "/v1/rules/run"); await refresh(); } catch (err) { + if (err.schemaNotReady) { + showConsoleError(err); + return; + } alert(`CSPM rules require the API secret: ${err.message}`); } finally { btn.disabled = false; @@ -64,13 +68,25 @@ function apiHeaders() { return { "X-API-Key": key }; } +async function errorFromResponse(res, path) { + let body = null; + try { + body = await res.json(); + } catch (err) { + body = null; + } + const error = new Error(`${path} returned ${res.status}`); + error.status = res.status; + if (body && body.code === "schema_not_ready" && body.error) { + error.schemaNotReady = true; + error.message = body.error; + } + return error; +} + async function fetchJSON(path) { const res = await fetch(path, { headers: apiHeaders() }); - if (!res.ok) { - const error = new Error(`${path} returned ${res.status}`); - error.status = res.status; - throw error; - } + if (!res.ok) throw await errorFromResponse(res, path); return res.json(); } @@ -79,6 +95,9 @@ function isUnauthorized(err) { } function consoleLoadError(err) { + if (err && err.schemaNotReady) { + return err.message; + } if (!isUnauthorized(err)) { return `Failed to load console: ${err.message}`; }