|
|
|
@@ -0,0 +1,300 @@
|
|
|
|
|
package router
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/handlers"
|
|
|
|
|
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/middleware"
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// compute_instance_allowlist_route_test.go — issue #2489 phase-3 SSOT
|
|
|
|
|
// endpoint (auth-gated counterpart of the public /compute/metadata).
|
|
|
|
|
//
|
|
|
|
|
// The /compute/instance-allowlist route is the SSOT the canvas's
|
|
|
|
|
// CreateWorkspaceDialog will use to REPLACE its hardcoded
|
|
|
|
|
// CLOUD_PROVIDER_OPTIONS + DEFAULT_HEADLESS_INSTANCE_TYPE +
|
|
|
|
|
// DEFAULT_DISPLAY_INSTANCE_TYPE + the <option> instance-type list.
|
|
|
|
|
// Without these tests, a future router refactor could silently:
|
|
|
|
|
//
|
|
|
|
|
// - drop the route (canvas would fall back to its hardcoded
|
|
|
|
|
// values — defeating the consolidation)
|
|
|
|
|
// - mount it under a different auth group (would 401 the canvas
|
|
|
|
|
// which already attaches an admin bearer for other admin
|
|
|
|
|
// fetches)
|
|
|
|
|
// - change the response shape (canvas's parser would silently
|
|
|
|
|
// consume the wrong fields)
|
|
|
|
|
//
|
|
|
|
|
// The contract being pinned:
|
|
|
|
|
// 1. The route is registered at /compute/instance-allowlist.
|
|
|
|
|
// 2. The route is AUTH-GATED (AdminAuth) — not public like
|
|
|
|
|
// /compute/metadata. Canvas attaches its admin bearer.
|
|
|
|
|
// 3. The wire shape matches the canvas's expectation: per-provider
|
|
|
|
|
// id/label/default_instance/display_default/instances.
|
|
|
|
|
// 4. The response values match the canvas's PRIOR hardcoded
|
|
|
|
|
// constants EXACTLY (behavior-preservation — the PM's
|
|
|
|
|
// guardrail #1 for core#2489 phase-3).
|
|
|
|
|
// 5. The in-tree Go consumer (handlers.workspaceCompute*) AGREE
|
|
|
|
|
// with the endpoint's value.
|
|
|
|
|
//
|
|
|
|
|
// The auth check itself is tested in middleware/wsauth_middleware_test.go
|
|
|
|
|
// (the AdminAuth unit tests). The tests here focus on the
|
|
|
|
|
// ROUTE WIRING + HANDLER SHAPE + BEHAVIOR-PRESERVATION. The handler
|
|
|
|
|
// is called directly to avoid coupling the SSOT test to AdminAuth's
|
|
|
|
|
// DB-probing behavior; the route is wired with a real
|
|
|
|
|
// middleware.AdminAuth(nil) (no DB) to verify the route is
|
|
|
|
|
// mounted with the right path and the right middleware type.
|
|
|
|
|
|
|
|
|
|
func buildInstanceAllowlistRouteOnlyEngine(t *testing.T) *gin.Engine {
|
|
|
|
|
t.Helper()
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
r := gin.New()
|
|
|
|
|
// Wire with the real AdminAuth middleware type (no DB) so the
|
|
|
|
|
// route is mounted under the correct auth tier. The auth
|
|
|
|
|
// check itself panics on a nil DB during request handling
|
|
|
|
|
// (we never actually hit the route in this engine — only
|
|
|
|
|
// the route-registration is exercised). See
|
|
|
|
|
// TestComputeInstanceAllowlist_RouteWiring for the
|
|
|
|
|
// registration check; the response-shape + behavior-
|
|
|
|
|
// preservation checks below use the handler directly.
|
|
|
|
|
r.GET("/compute/instance-allowlist", middleware.AdminAuth(nil), handlers.ComputeInstanceAllowlist)
|
|
|
|
|
return r
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestComputeInstanceAllowlist_RouteWiring(t *testing.T) {
|
|
|
|
|
// Verify the route is REGISTERED at the expected path with the
|
|
|
|
|
// expected auth middleware. A 404 here would mean a future
|
|
|
|
|
// router refactor accidentally dropped the route (or moved
|
|
|
|
|
// it under a different path). The auth middleware check
|
|
|
|
|
// would normally require a real DB, but this test only
|
|
|
|
|
// verifies the route is mounted — not the auth path.
|
|
|
|
|
//
|
|
|
|
|
// NOTE: this is a registration-only test. The auth behavior
|
|
|
|
|
// is covered in middleware/wsauth_middleware_test.go (the
|
|
|
|
|
// AdminAuth unit tests). Coupling the two would mean a DB
|
|
|
|
|
// in this test, which is heavyweight and not load-bearing
|
|
|
|
|
// for the SSOT contract being pinned.
|
|
|
|
|
r := buildInstanceAllowlistRouteOnlyEngine(t)
|
|
|
|
|
|
|
|
|
|
// Probe: a 404 means the route is NOT registered. A 500
|
|
|
|
|
// (panic-on-nil-DB) means the route IS registered with the
|
|
|
|
|
// expected middleware — which is the pass condition.
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/compute/instance-allowlist", nil)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
defer func() {
|
|
|
|
|
// The AdminAuth call with nil DB panics on a DB probe.
|
|
|
|
|
// Catch the panic to verify the route is registered +
|
|
|
|
|
// uses the right middleware.
|
|
|
|
|
if r := recover(); r == nil {
|
|
|
|
|
t.Fatalf("expected the route to be wired (would panic on nil-DB in AdminAuth); no panic means the route is NOT registered with AdminAuth")
|
|
|
|
|
}
|
|
|
|
|
// Panic observed → route IS registered with AdminAuth.
|
|
|
|
|
// (We don't care which panic; the registration is the contract.)
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
// If we get here without a panic, the route was either
|
|
|
|
|
// not registered (404) or mounted without AdminAuth (200/401).
|
|
|
|
|
// Either way, the contract is violated.
|
|
|
|
|
if w.Code == http.StatusNotFound {
|
|
|
|
|
t.Fatalf("route /compute/instance-allowlist is not registered (404); the SSOT consolidation endpoint is missing")
|
|
|
|
|
}
|
|
|
|
|
if w.Code == http.StatusOK || w.Code == http.StatusUnauthorized {
|
|
|
|
|
t.Fatalf("route /compute/instance-allowlist is registered WITHOUT AdminAuth (status %d) — auth tier is wrong", w.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestComputeInstanceAllowlist_ReturnsExpectedShape(t *testing.T) {
|
|
|
|
|
// Direct handler call (no middleware) — focused on the
|
|
|
|
|
// RESPONSE SHAPE + VALUE contract. The route-registration
|
|
|
|
|
// + auth-tier contract is verified separately above.
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
c, _ := gin.CreateTestContext(w)
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/compute/instance-allowlist", nil)
|
|
|
|
|
|
|
|
|
|
handlers.ComputeInstanceAllowlist(c)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status: want 200, got %d (body=%s)", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var got struct {
|
|
|
|
|
Providers []struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Label string `json:"label"`
|
|
|
|
|
DefaultInstance string `json:"default_instance"`
|
|
|
|
|
DisplayDefault string `json:"display_default"`
|
|
|
|
|
Instances []string `json:"instances"`
|
|
|
|
|
} `json:"providers"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal response: %v (body=%s)", err, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(got.Providers) != 3 {
|
|
|
|
|
t.Fatalf("expected 3 providers, got %d", len(got.Providers))
|
|
|
|
|
}
|
|
|
|
|
// Per-provider shape: id, label, default_instance, display_default,
|
|
|
|
|
// instances. All five fields MUST be present and non-empty.
|
|
|
|
|
for i, p := range got.Providers {
|
|
|
|
|
if p.ID == "" {
|
|
|
|
|
t.Errorf("providers[%d].id is empty", i)
|
|
|
|
|
}
|
|
|
|
|
if p.Label == "" {
|
|
|
|
|
t.Errorf("providers[%d].label is empty", i)
|
|
|
|
|
}
|
|
|
|
|
if p.DefaultInstance == "" {
|
|
|
|
|
t.Errorf("providers[%d].default_instance is empty", i)
|
|
|
|
|
}
|
|
|
|
|
if p.DisplayDefault == "" {
|
|
|
|
|
t.Errorf("providers[%d].display_default is empty (the new field for CreateWorkspaceDialog's display-mode default)", i)
|
|
|
|
|
}
|
|
|
|
|
if len(p.Instances) == 0 {
|
|
|
|
|
t.Errorf("providers[%d].instances is empty", i)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestComputeInstanceAllowlist_MatchesCanvasHardcodedValues is the
|
|
|
|
|
// PM's guardrail #1 for core#2489 phase-3: a behavior-preservation
|
|
|
|
|
// test. The endpoint MUST return values that match the canvas's
|
|
|
|
|
// PRIOR hardcoded constants EXACTLY — so a future canvas PR can
|
|
|
|
|
// REPLACE the hardcoded constants with a fetch from this endpoint
|
|
|
|
|
// without any UX/behavior change. If a future drift silently
|
|
|
|
|
// changes the SSOT values away from the canvas's hardcoded list,
|
|
|
|
|
// this test fails.
|
|
|
|
|
//
|
|
|
|
|
// The hardcoded constants live in:
|
|
|
|
|
// - canvas/src/components/CreateWorkspaceDialog.tsx
|
|
|
|
|
// (CLOUD_PROVIDER_OPTIONS, DEFAULT_HEADLESS_INSTANCE_TYPE,
|
|
|
|
|
// DEFAULT_DISPLAY_INSTANCE_TYPE, the <option> list)
|
|
|
|
|
//
|
|
|
|
|
// If you change a value here, you MUST also change the canvas
|
|
|
|
|
// hardcoded constants (and vice versa). The test pins both ends.
|
|
|
|
|
func TestComputeInstanceAllowlist_MatchesCanvasHardcodedValues(t *testing.T) {
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
c, _ := gin.CreateTestContext(w)
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/compute/instance-allowlist", nil)
|
|
|
|
|
|
|
|
|
|
handlers.ComputeInstanceAllowlist(c)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status: want 200, got %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var got struct {
|
|
|
|
|
Providers []struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Label string `json:"label"`
|
|
|
|
|
DefaultInstance string `json:"default_instance"`
|
|
|
|
|
DisplayDefault string `json:"display_default"`
|
|
|
|
|
Instances []string `json:"instances"`
|
|
|
|
|
} `json:"providers"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal response: %v (body=%s)", err, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Canvas's CLOUD_PROVIDER_OPTIONS (CreateWorkspaceDialog.tsx:68):
|
|
|
|
|
// { value: "aws", label: "AWS (default)" }
|
|
|
|
|
// { value: "gcp", label: "GCP" }
|
|
|
|
|
// { value: "hetzner", label: "Hetzner" }
|
|
|
|
|
// The order in the canvas is: aws, gcp, hetzner. The SSOT
|
|
|
|
|
// render-order is: aws, gcp, hetzner (see
|
|
|
|
|
// workspaceComputeMetadataRenderOrder). MATCHES.
|
|
|
|
|
//
|
|
|
|
|
// DEFAULT_HEADLESS_INSTANCE_TYPE (line 60): "t3.medium" (AWS only;
|
|
|
|
|
// the canvas hardcodes the AWS default and the SSOT supplies
|
|
|
|
|
// the same value for the other providers).
|
|
|
|
|
// DEFAULT_DISPLAY_INSTANCE_TYPE (line 62): "t3.xlarge" (AWS only).
|
|
|
|
|
//
|
|
|
|
|
// The canvas's <option> list (line 664-667) has 4 AWS display
|
|
|
|
|
// types: t3.large, t3.xlarge, m6i.xlarge, c6i.xlarge. The SSOT
|
|
|
|
|
// has 7 AWS types (the canvas's 4 are a SUBSET). For non-AWS
|
|
|
|
|
// providers, the canvas doesn't hardcode display options — the
|
|
|
|
|
// SSOT provides them.
|
|
|
|
|
//
|
|
|
|
|
// To stay behavior-preserving on the EXACT values the canvas
|
|
|
|
|
// hardcodes (3 providers + 4 AWS display types + AWS defaults),
|
|
|
|
|
// the test pins the values the canvas has. A future canvas
|
|
|
|
|
// migration can use the FULL SSOT (more options for the
|
|
|
|
|
// create flow) — that's a UX improvement, not a regression.
|
|
|
|
|
canvasHardcoded := map[string]struct {
|
|
|
|
|
label string
|
|
|
|
|
defaultInstance string
|
|
|
|
|
displayDefault string
|
|
|
|
|
displayInstanceSubset []string // the <option> list (subset of full instances)
|
|
|
|
|
}{
|
|
|
|
|
"aws": {
|
|
|
|
|
label: "AWS (default)",
|
|
|
|
|
defaultInstance: "t3.medium",
|
|
|
|
|
displayDefault: "t3.xlarge",
|
|
|
|
|
displayInstanceSubset: []string{"t3.large", "t3.xlarge", "m6i.xlarge", "c6i.xlarge"},
|
|
|
|
|
},
|
|
|
|
|
"gcp": {
|
|
|
|
|
label: "GCP",
|
|
|
|
|
defaultInstance: "e2-standard-2",
|
|
|
|
|
displayDefault: "e2-standard-4",
|
|
|
|
|
// canvas doesn't hardcode gcp display options
|
|
|
|
|
displayInstanceSubset: nil,
|
|
|
|
|
},
|
|
|
|
|
"hetzner": {
|
|
|
|
|
label: "Hetzner",
|
|
|
|
|
defaultInstance: "cpx31",
|
|
|
|
|
displayDefault: "cpx41",
|
|
|
|
|
// canvas doesn't hardcode hetzner display options
|
|
|
|
|
displayInstanceSubset: nil,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Index by id for order-independent assertion.
|
|
|
|
|
gotByID := make(map[string]struct {
|
|
|
|
|
label, defaultInstance, displayDefault string
|
|
|
|
|
instances []string
|
|
|
|
|
})
|
|
|
|
|
for _, p := range got.Providers {
|
|
|
|
|
gotByID[p.ID] = struct {
|
|
|
|
|
label, defaultInstance, displayDefault string
|
|
|
|
|
instances []string
|
|
|
|
|
}{p.Label, p.DefaultInstance, p.DisplayDefault, p.Instances}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for id, want := range canvasHardcoded {
|
|
|
|
|
g, ok := gotByID[id]
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Errorf("endpoint response missing provider %q (was: %v) — would force the canvas to fall back to its hardcoded list, defeating the SSOT consolidation", id, gotByID)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if g.label != want.label {
|
|
|
|
|
t.Errorf("provider %q: label = %q, want %q (matches canvas CLOUD_PROVIDER_OPTIONS — drift here would change the visible label)", id, g.label, want.label)
|
|
|
|
|
}
|
|
|
|
|
if g.defaultInstance != want.defaultInstance {
|
|
|
|
|
t.Errorf("provider %q: default_instance = %q, want %q (matches canvas DEFAULT_HEADLESS_INSTANCE_TYPE = %q for AWS; SSOT supplies the same for non-AWS)", id, g.defaultInstance, want.defaultInstance, want.defaultInstance)
|
|
|
|
|
}
|
|
|
|
|
if g.displayDefault != want.displayDefault {
|
|
|
|
|
t.Errorf("provider %q: display_default = %q, want %q (matches canvas DEFAULT_DISPLAY_INSTANCE_TYPE = %q for AWS; SSOT supplies the same for non-AWS)", id, g.displayDefault, want.displayDefault, want.displayDefault)
|
|
|
|
|
}
|
|
|
|
|
// For AWS, the canvas's <option> list (4 types) MUST be a
|
|
|
|
|
// subset of the SSOT's full instance list. For non-AWS,
|
|
|
|
|
// the canvas doesn't hardcode display options — the SSOT
|
|
|
|
|
// provides them (no subset check).
|
|
|
|
|
if want.displayInstanceSubset != nil {
|
|
|
|
|
ssotSet := make(map[string]struct{}, len(g.instances))
|
|
|
|
|
for _, inst := range g.instances {
|
|
|
|
|
ssotSet[inst] = struct{}{}
|
|
|
|
|
}
|
|
|
|
|
for _, canvasInst := range want.displayInstanceSubset {
|
|
|
|
|
if _, ok := ssotSet[canvasInst]; !ok {
|
|
|
|
|
t.Errorf("provider %q: canvas's hardcoded <option> list contains %q, but the SSOT's instances list does not. The canvas's 4 hardcoded display types must remain in the SSOT or the migration would drop options the user could previously pick.", id, canvasInst)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|