Files
mesh-controller/examples/route-proxy/routes_test.go
T
jschoubben b48572bdfc A null body limit is refused, not read as no limit; the gate on the wrong machine is refused
Review of the registry hand-over. The proxy's bodyLimit treated an absent key and a JSON
null alike, so a `max-request-body: null` was served unlimited here while the adapter
skipped it and the catalogue refused it — one provider carrying what the others refuse.
Presence is now checked before the value is read.

The catalogue-backed test asserted the gate pulling the store in beside it as the feature.
It was the fault: a node-scoped requirement with one candidate installs that candidate, so
a gate assigned to a machine without the store raised a second, empty one there behind the
real credentials and the public name. The store's seat is one per mesh now (mesh-catalog),
and the test asserts the refusal by name. Delete is asserted only behind the lock.

hq ADR 0082/0104, the registry hand-over.
2026-09-23 23:35:29 +02:00

290 lines
11 KiB
Go

package main
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func write(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "routes.json")
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return path
}
// A route is a grant: the consumer supplies a target, and where that machine is comes from the
// mesh rather than from a naming convention the proxy has to know.
func TestARouteGoesToWhereTheMeshSaysTheConsumerIs(t *testing.T) {
routes, err := routesFrom(write(t, `{"contributions":1,"requirement":"route","given":[
{"from":"app","node":"laptop","at":"laptop.internal","values":{"name":"App.Example","port":8080}}
]}`))
if err != nil {
t.Fatal(err)
}
// Lower-cased, because a Host header is not case-sensitive and a route that only answers the
// spelling in the manifest answers half the requests made to it.
if routes["app.example"].Target != "http://laptop.internal:8080" {
t.Fatalf("the route does not point at the consumer: %v", routes)
}
}
// A workload beside the proxy is ordinary, and reaching it over loopback is both correct and the
// only thing that works when there is no private network.
func TestAConsumerOnTheProxysOwnMachineIsReachedOverLoopback(t *testing.T) {
routes, err := routesFrom(write(t, `{"given":[
{"from":"app","node":"anchor","values":{"name":"app.example","port":9000}}
]}`))
if err != nil {
t.Fatal(err)
}
if routes["app.example"].Target != "http://127.0.0.1:9000" {
t.Fatalf("a workload on this machine was not reachable: %v", routes)
}
}
// Skipped rather than served wrongly. A route with no port would proxy to :0.
func TestAContributionMissingWhatARouteNeedsIsSkipped(t *testing.T) {
routes, err := routesFrom(write(t, `{"given":[
{"from":"a","node":"n","at":"n.internal","values":{"name":"no-port.example"}},
{"from":"b","node":"n","at":"n.internal","values":{"port":8080}},
{"from":"c","node":"n","at":"n.internal","values":{"name":"fine.example","port":8080}}
]}`))
if err != nil {
t.Fatal(err)
}
if len(routes) != 1 || routes["fine.example"].Target == "" {
t.Fatalf("an unusable contribution was served: %v", routes)
}
}
// End to end through the proxy itself: a request for the name reaches the workload, and a name
// nobody asked for is refused in a way that says what IS served.
func TestTheProxyReachesTheWorkloadAndNamesWhatItServes(t *testing.T) {
workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("the workload"))
}))
defer workload.Close()
target := strings.TrimPrefix(workload.URL, "http://")
host, port, _ := strings.Cut(target, ":")
held := newTable()
held.set(map[string]route{"app.example": {Target: "http://" + host + ":" + port}})
proxy := httptest.NewServer(handler(held))
defer proxy.Close()
asked, err := http.NewRequest(http.MethodGet, proxy.URL, nil)
if err != nil {
t.Fatal(err)
}
asked.Host = "app.example"
answer, err := http.DefaultClient.Do(asked)
if err != nil {
t.Fatal(err)
}
defer answer.Body.Close()
if answer.StatusCode != http.StatusOK {
t.Fatalf("a request for a served name got %d", answer.StatusCode)
}
// And a name that is not served says which are — a route withdrawn and a name that never
// existed are different things, and a bare 404 makes an operator go and read the mesh.
other, _ := http.NewRequest(http.MethodGet, proxy.URL, nil)
other.Host = "nobody.example"
refused, err := http.DefaultClient.Do(other)
if err != nil {
t.Fatal(err)
}
defer refused.Body.Close()
if refused.StatusCode != http.StatusNotFound {
t.Fatalf("a name nobody asked for got %d", refused.StatusCode)
}
body := make([]byte, 256)
n, _ := refused.Body.Read(body)
if !strings.Contains(string(body[:n]), "app.example") {
t.Fatalf("the refusal does not say what is served: %s", body[:n])
}
}
// The file is the whole truth about who has a route, so the table replaces rather than merges.
//
// Merging would keep serving a name whose module was unassigned — the stale-route fault
// 08-connectivity lists as open, reintroduced one level down. A stale public name pointing at
// nothing fails more visibly than a stale grant, which is exactly why it must not survive.
func TestWithdrawingARouteStopsServingIt(t *testing.T) {
held := newTable()
held.set(map[string]route{
"going.example": {Target: "http://a.internal:80"},
"staying.example": {Target: "http://b.internal:80"},
})
held.set(map[string]route{"staying.example": {Target: "http://b.internal:80"}})
if _, still := held.find("going.example"); still {
t.Fatal("a route whose module was unassigned is still served")
}
if _, kept := held.find("staying.example"); !kept {
t.Fatal("withdrawing one route took another with it")
}
}
// A Host header carries a port and the name does not.
func TestARequestNamingAPortStillFindsItsRoute(t *testing.T) {
held := newTable()
held.set(map[string]route{"app.example": {Target: "http://a.internal:8080"}})
if _, found := held.find("app.example:8080"); !found {
t.Fatal("a request to app.example:8080 did not find the route for app.example")
}
}
// Defends novox/hq 04-ISSUES/004: issuance targets staging unless something says otherwise.
//
// The failure this guards is not a broken proxy. It is a working one that quietly spends a
// production quota which does not replenish for a week, on exactly the work most likely to
// iterate.
func TestTheIssuerIsStagingUnlessNamed(t *testing.T) {
t.Setenv("ACME_DIRECTORY", "")
if got := issuer(); !strings.Contains(got, "staging") {
t.Fatalf("with nothing set the issuer is %q, and a default that spends production quota "+
"is a default nobody chose", got)
}
t.Setenv("ACME_DIRECTORY", "https://acme-v02.api.letsencrypt.org/directory")
if got := issuer(); strings.Contains(got, "staging") {
t.Fatalf("an issuer was named explicitly and %q was used instead", got)
}
}
// A certificate is only ever asked for on a name the mesh routes here.
//
// **Without this, anything that can reach the port spends the quota.** A scan sending arbitrary
// names, or one misconfigured client, becomes a stream of failed orders against the account's
// rate limit — and the proxy would look healthy throughout.
func TestNoCertificateIsAskedForOnAnUnroutedName(t *testing.T) {
held := newTable()
held.set(map[string]route{"photos.example": {Target: "http://127.0.0.1:8080"}})
policy := onlyWhatTheMeshSaid(held)
if err := policy(context.Background(), "photos.example"); err != nil {
t.Errorf("a name the mesh routes here was refused a certificate: %v", err)
}
for _, name := range []string{"unknown.example", "", "photos.example.evil"} {
if err := policy(context.Background(), name); err == nil {
t.Errorf("a certificate would be ordered for %q, which the mesh never mentioned", name)
}
}
}
// A route withdrawn stops being certifiable, without the proxy restarting.
func TestWithdrawingARouteWithdrawsItsCertificate(t *testing.T) {
held := newTable()
held.set(map[string]route{"photos.example": {Target: "http://127.0.0.1:8080"}})
policy := onlyWhatTheMeshSaid(held)
if err := policy(context.Background(), "photos.example"); err != nil {
t.Fatal(err)
}
held.set(nil)
if err := policy(context.Background(), "photos.example"); err == nil {
t.Fatal("a withdrawn route can still order certificates, so the policy read a copy taken " +
"once rather than what is served now")
}
}
// The registry's hand-over (novox/hq ADR 0082, ADR 0104): a registry takes image layers in single
// requests of gigabytes, and its public name was served by the predecessor with a twenty-gigabyte
// body limit. The contribution now says so, and this proxy reads it as written — and a limit it
// cannot read is a route it does not serve, like a port that is not one.
func TestABodyLimitIsReadFromTheContributionOrTheRouteIsSkipped(t *testing.T) {
routes, err := routesFrom(write(t, `{"given":[
{"from":"gate","node":"anchor","values":{"name":"registry-api.example","port":5001,"max-request-body":21474836480}},
{"from":"app","node":"anchor","values":{"name":"app.example","port":8080}},
{"from":"odd","node":"anchor","values":{"name":"odd.example","port":8081,"max-request-body":"20g"}},
{"from":"none","node":"anchor","values":{"name":"none.example","port":8082,"max-request-body":0}},
{"from":"nul","node":"anchor","values":{"name":"nul.example","port":8083,"max-request-body":null}}
]}`))
if err != nil {
t.Fatal(err)
}
if got := routes["registry-api.example"].MaxRequestBody; got != 21474836480 {
t.Errorf("the limit did not arrive as written: %d", got)
}
if got := routes["app.example"].MaxRequestBody; got != 0 {
t.Errorf("a route that asked for no limit was given one: %d", got)
}
// A `null` is not absence: the adapter skips it and the catalogue refuses it, and a proxy
// that read it as "no limit" would be the one provider carrying what the others refuse.
for _, skipped := range []string{"odd.example", "none.example", "nul.example"} {
if _, served := routes[skipped]; served {
t.Errorf("%s asked for a limit that is not a number of bytes and was served anyway", skipped)
}
}
}
// A body past the route's limit is refused as too large — whether its length is declared up front
// or only discovered while it is read — and a body within it reaches the workload whole. Refused
// as 413, not 502: a push that is too large must be told so, not told the registry is down.
func TestABodyPastTheRoutesLimitIsRefusedAsTooLarge(t *testing.T) {
var received int64
workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n, _ := io.Copy(io.Discard, r.Body)
received = n
w.WriteHeader(http.StatusCreated)
}))
defer workload.Close()
held := newTable()
held.set(map[string]route{
"limited.example": {Target: workload.URL, MaxRequestBody: 1024},
"unlimited.example": {Target: workload.URL},
})
proxy := httptest.NewServer(handler(held))
defer proxy.Close()
push := func(host string, body []byte, declared bool) int {
t.Helper()
var reader io.Reader = bytes.NewReader(body)
if !declared {
// A reader that is not a bytes.Reader carries no length: the request goes out chunked
// and the proxy learns the size only by reading it.
reader = io.MultiReader(bytes.NewReader(body))
}
asked, err := http.NewRequest(http.MethodPut, proxy.URL+"/v2/blob", reader)
if err != nil {
t.Fatal(err)
}
asked.Host = host
answer, err := http.DefaultClient.Do(asked)
if err != nil {
t.Fatal(err)
}
defer answer.Body.Close()
_, _ = io.Copy(io.Discard, answer.Body)
return answer.StatusCode
}
small, large := bytes.Repeat([]byte("x"), 1000), bytes.Repeat([]byte("y"), 4096)
if got := push("limited.example", small, true); got != http.StatusCreated || received != 1000 {
t.Fatalf("a body within the limit got %d and %d bytes arrived", got, received)
}
if got := push("limited.example", large, true); got != http.StatusRequestEntityTooLarge {
t.Fatalf("a declared body past the limit got %d, not 413", got)
}
if got := push("limited.example", large, false); got != http.StatusRequestEntityTooLarge {
t.Fatalf("an undeclared body past the limit got %d, not 413", got)
}
// And a route that asked for no limit carries whatever it is given.
if got := push("unlimited.example", large, true); got != http.StatusCreated || received != 4096 {
t.Fatalf("a route with no limit refused or truncated a body: %d, %d bytes", got, received)
}
}