diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 4752b11..4780434 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -3,7 +3,9 @@ // The API surface mirrors the OpenAPI document the real backend publishes at // /api/openapi.json: every operation in that document is routed here, so a UI // pointed at this server sees the same set of endpoints rather than a wall of -// 404s. Most are placeholders that answer 500 UNIMPLEMENTED. Ten are real: +// 404s. The /contexts operations are routed too, though they postdate that +// document; see the note on them in apiRoutes. Most are placeholders that answer +// 500 UNIMPLEMENTED. Ten are real: // // - GET /auth/config reports authentication as disabled, so a UI can finish // initializing without a Keycloak realm behind it. @@ -151,6 +153,23 @@ var apiRoutes = []Route{ {http.MethodGet, "/config/mcp-gateway-status", unimplemented}, {http.MethodGet, "/config/platform-status", unimplemented}, + // contexts + // + // The context resource API (rossoctl/rossoctl#2392) is newer than the OpenAPI + // document the rest of this table was transcribed from, so these are listed + // from the paths internal/apiclient requests rather than from that document. + // They are placeholders like most of the table, but they have to be *present*: + // an unlisted path falls through to the mux and 404s, and a 404 from a cortex + // context is the same answer a server that predates the context API gives — + // which is what `contexts list` reads as "this server does not support context + // infrastructure". A local cortex does not support it either, but for an + // unrelated reason, and reporting the two identically sends the user looking at + // their server's version instead of at their current context. + {http.MethodPost, "/contexts", unimplemented}, + {http.MethodGet, "/contexts/{namespace}", unimplemented}, + {http.MethodDelete, "/contexts/{namespace}/{name}", unimplemented}, + {http.MethodGet, "/contexts/{namespace}/{name}", unimplemented}, + // namespaces {http.MethodGet, "/namespaces", namespacesRoute}, diff --git a/internal/serve/serve_test.go b/internal/serve/serve_test.go index 81f2a84..aa8ed19 100644 --- a/internal/serve/serve_test.go +++ b/internal/serve/serve_test.go @@ -325,16 +325,26 @@ func TestListenPortInUse(t *testing.T) { } // TestRouteTableMatchesOpenAPI guards the operation count against accidental -// edits to the route table. The backend's OpenAPI document lists 44 operations -// under /api/v1 plus /health and /ready at the root. +// edits to the route table. The count is 48: 44 operations from the backend's +// OpenAPI document under /api/v1, plus the 4 context operations described below, +// with /health and /ready at the root counted separately. // // It was 43 until PUT /agents/{namespace}/{name}/identity-config was added: the // backend has always published it (agents.py declares it beside the GET), and the // transcription had simply missed it. Raising this number is therefore only // correct alongside evidence that the document grew — otherwise a route invented // here would be waved through. +// +// The 4 context routes (POST /contexts, GET /contexts/{namespace}, and GET and +// DELETE /contexts/{namespace}/{name}) are the evidence-backed exception to +// "transcribed from the document": they are the context resource API from +// rossoctl/rossoctl#2392, which postdates the document this table was built from, +// and they are listed from the paths internal/apiclient actually requests. +// TestContextRoutesAreReachedByTheClient in wire_test.go is what holds them to +// that — it drives the real client, so a path here that the client does not ask +// for, or vice versa, fails. func TestRouteTableMatchesOpenAPI(t *testing.T) { - if got, want := len(APIRoutes()), 44; got != want { + if got, want := len(APIRoutes()), 48; got != want { t.Errorf("API route count = %d, want %d", got, want) } if got, want := len(HealthRoutes()), 2; got != want { diff --git a/internal/serve/wire_test.go b/internal/serve/wire_test.go index 411f567..d87b017 100644 --- a/internal/serve/wire_test.go +++ b/internal/serve/wire_test.go @@ -1,9 +1,12 @@ package serve import ( + "context" "encoding/json" + "errors" "net/http" "reflect" + "strings" "testing" "github.com/rossoctl/rossoctl-cli/internal/agentapi" @@ -319,3 +322,70 @@ func TestAgentCardNullDescriptionDecodes(t *testing.T) { t.Error("decode was vacuous; nothing arrived") } } + +// TestContextRoutesAreReachedByTheClient verifies each context operation the CLI +// can issue lands on a registered route, by driving the real client against this +// server rather than by requesting a hand-written path. +// +// This is the regression test for the bug it was written for: the /contexts paths +// were absent from the route table, so every context command 404'd. A 404 is not a +// neutral failure here — `contexts list` reads it as "this Rosso server does not +// support context infrastructure" (see contextListError), which is a true statement +// about an old *server* and a misleading one about a local cortex, sending the user +// to check their server's version instead of their current context. +// +// Asserting through apiclient is what makes this catch drift: a test requesting +// "/contexts/ns/name" by hand would keep passing if the client changed the path it +// asks for, which is exactly how the table fell behind in the first place. +func TestContextRoutesAreReachedByTheClient(t *testing.T) { + stubGetter(t, mixedInstances()) + ts := newTestServer(t, "/api/v1") + client := &apiclient.Client{BaseURL: ts.URL + "/api/v1/"} + + // requireUnimplemented asserts the call reached a placeholder route: a 500 + // carrying the UNIMPLEMENTED detail, never a 404 from the mux. + requireUnimplemented := func(t *testing.T, op string, err error) { + t.Helper() + if err == nil { + t.Fatalf("%s: expected an error from a placeholder route", op) + } + var statusErr *apiclient.StatusError + if !errors.As(err, &statusErr) { + t.Fatalf("%s: error is not a StatusError: %v", op, err) + } + if statusErr.StatusCode == http.StatusNotFound { + t.Fatalf("%s: got 404 — the path the client requests is not in the route table: %v", + op, err) + } + if statusErr.StatusCode != http.StatusInternalServerError { + t.Errorf("%s: status = %d, want 500", op, statusErr.StatusCode) + } + if !strings.Contains(statusErr.Body, unimplementedMessage) { + t.Errorf("%s: body = %q, want it to carry %q", op, statusErr.Body, unimplementedMessage) + } + } + + ctx := context.Background() + + t.Run("CreateContext", func(t *testing.T) { + _, err := client.CreateContext(ctx, &apiclient.CreateContextRequest{ + Name: "research", Namespace: "nsA", Type: "workspace", + }) + requireUnimplemented(t, "CreateContext", err) + }) + + t.Run("ListContexts", func(t *testing.T) { + _, err := client.ListContexts(ctx, "nsA") + requireUnimplemented(t, "ListContexts", err) + }) + + t.Run("GetContext", func(t *testing.T) { + _, err := client.GetContext(ctx, "nsA", "research") + requireUnimplemented(t, "GetContext", err) + }) + + t.Run("DeleteContext", func(t *testing.T) { + err := client.DeleteContext(ctx, "nsA", "research") + requireUnimplemented(t, "DeleteContext", err) + }) +}