mirror of
https://github.com/hanzoai/compute.git
synced 2026-10-30 14:50:41 +00:00
There were two machine collections. One read a table visor rebuilds from the
organization's own provider credentials; the other read the cloud API on the
house account. They were never two kinds of thing: object.Machine and
service.Machine both key on (Owner, Name) and both carry Id as the provider's
id, so only the SOURCE differed — which is a property of a machine, not a
reason for a second address. A caller wanting "the machines I have" had to ask
both and join them, and one that asked once got a partial answer without being
told. GET /v1/machines now answers from both, tagged by source, registry
winning a collision.
The item is at /v1/machines/{owner}/{name}; adding to the collection is POST.
Six verb addresses and /v1/machines/launch retire to 410.
The node-pool family had the reverse problem: the routes moved to item
addresses in an earlier commit and the handlers kept reading the pool from the
body and the query, so delete and scale answered for whatever the body said.
Both now take the name from the address and the owner from the token, and scale
resolves the provider, cluster and upstream pool id from the row visor already
keeps — a caller that has to repeat them can contradict them.
AddMachine is deleted with the console button that called it: it inserted a row
no provider sync owns, and its address now means launch, which spends money.
GetMachines and LaunchMachine were unrouted.
Also here, because the tests found them:
- isResellComputePath keyed on /v1/machines/launch, so POST /v1/machines would
have been denied for every authenticated user.
- A doc block for a handler that moved stayed behind and described the next
function down, publishing UpdateMachine as GET /get-machine.
- The tenant and pool tests registered a shape routers.Route no longer serves,
so they exercised the query fallback rather than the path the router uses.
They now register what production registers and name the victim's org in the
ADDRESS, which is the stronger form of the same attack.
- Two request helpers fixed the method, so an item table answered 404 and a 404
reads as a refusal. One helper, method as a parameter.
- The launch tenant test proved its point through an error message that named
an org; the handler now refuses earlier, for an unrelated reason. The subject
is the resolver itself, which is the one place that decides, with a control
covering both the query and the newer path vector.
- The unauthenticated control named an address that retires to 410, so it held
no matter what the filter did.
- pkg/visor's bind raced its own TempDir: Close returns before the transport
goroutine stops writing socket files. It now waits for the name to leave the
plane.
117 lines
4.7 KiB
Go
117 lines
4.7 KiB
Go
// Copyright 2026 Hanzo Industries Inc. All Rights Reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package routers
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// These tests drive registerAPI alone, with no filter chain: the question is
|
|
// which route MATCHES, and the chain would answer before the router's choice
|
|
// could be observed.
|
|
|
|
// TestAgentsIsNotAMachineId pins WHICH handler answers /v1/machines/agents.
|
|
//
|
|
// The collection sits under the member's own prefix, so the literal "agents" and
|
|
// the pattern ":id" compete for it. If the pattern wins, a list of the org's
|
|
// bindings silently becomes a lookup of a machine named "agents" — and the caller
|
|
// gets a well-formed answer about the wrong thing, which is the failure worth a
|
|
// test.
|
|
//
|
|
// It does NOT test registration order, and that is the finding: fiber prefers the
|
|
// static segment however the two were registered (moving registerAgent below the
|
|
// :id routes leaves this passing), so an ordering rule here would be folklore
|
|
// dressed as a reason. What is real is the outcome, so the outcome is asserted.
|
|
//
|
|
// The proof is the SHAPE of the refusal. GetComputeMachine has no store in a test
|
|
// and answers the casibase envelope inside an HTTP 200; ListAgents is a typed op
|
|
// and refuses an anonymous caller with a real 403 and no envelope at all.
|
|
func TestAgentsIsNotAMachineId(t *testing.T) {
|
|
app := zip.New(zip.Config{})
|
|
registerAPI(app)
|
|
|
|
res, err := app.Fiber().Test(httptest.NewRequest(http.MethodGet, "/v1/machines/agents", nil))
|
|
if err != nil {
|
|
t.Fatalf("GET /v1/machines/agents: %v", err)
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
// The typed list op refuses refuseNoOrg with a real status code. The
|
|
// untyped machine handler would answer 200 and put its failure in the body.
|
|
if res.StatusCode == http.StatusOK {
|
|
t.Fatal("GET /v1/machines/agents = 200 — GetComputeMachine answered, so the " +
|
|
":id pattern captured the literal and a binding list reads as a machine lookup")
|
|
}
|
|
if res.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("GET /v1/machines/agents = %d, want 403 from ListAgents", res.StatusCode)
|
|
}
|
|
}
|
|
|
|
// TestMachineItemStillRoutes is the control. Without it the test above passes
|
|
// just as well when the machine item has been deleted outright, which is the
|
|
// failure it is least able to notice on its own.
|
|
//
|
|
// The collision it used to guard is gone by construction: a machine is
|
|
// /v1/machines/{owner}/{name}, three segments, and /v1/machines/agents is two.
|
|
// They cannot be confused for one another whatever order they register in.
|
|
func TestMachineItemStillRoutes(t *testing.T) {
|
|
app := zip.New(zip.Config{})
|
|
registerAPI(app)
|
|
|
|
res, err := app.Fiber().Test(httptest.NewRequest(http.MethodGet, "/v1/machines/acme/drop-a", nil))
|
|
if err != nil {
|
|
t.Fatalf("GET /v1/machines/acme/drop-a: %v", err)
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
// The untyped handler ran: it answers HTTP 200 and carries its verdict in the
|
|
// envelope. A 404 would mean the route is gone.
|
|
if res.StatusCode == http.StatusNotFound {
|
|
t.Fatalf("GET /v1/machines/acme/drop-a = 404 — the machine route is not registered")
|
|
}
|
|
}
|
|
|
|
// TestAgentBindIsPut pins the verb. The bind is idempotent, so it is a PUT on the
|
|
// machine's own agent sub-resource; the old POST .../bind-agent address is gone,
|
|
// and a caller still sending it must get a clean 404 rather than a route that
|
|
// half-works.
|
|
func TestAgentBindIsPut(t *testing.T) {
|
|
app := zip.New(zip.Config{})
|
|
registerAPI(app)
|
|
|
|
gone, err := app.Fiber().Test(httptest.NewRequest(http.MethodPost, "/v1/machines/acme/drop-a/bind-agent", nil))
|
|
if err != nil {
|
|
t.Fatalf("POST /v1/machines/acme/drop-a/bind-agent: %v", err)
|
|
}
|
|
defer gone.Body.Close()
|
|
if gone.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("POST /v1/machines/acme/drop-a/bind-agent = %d, want 404 — the old address must be gone", gone.StatusCode)
|
|
}
|
|
|
|
// And the new one exists: it refuses (no org context), it does not 404.
|
|
res, err := app.Fiber().Test(httptest.NewRequest(http.MethodPut, "/v1/machines/acme/drop-a/agent", nil))
|
|
if err != nil {
|
|
t.Fatalf("PUT /v1/machines/acme/drop-a/agent: %v", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode == http.StatusNotFound {
|
|
t.Fatalf("PUT /v1/machines/acme/drop-a/agent = 404 — the bind op is not registered")
|
|
}
|
|
}
|