mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
Every release since v1.5.72 died in this gate on three tests pinned to answers the toolchain no longer gives, so no image or site was published for any of them: - binding: from Go 1.27 encoding/json names a field from the value whose UnmarshalJSON failed, so a nested custom type reports 'a', not 's.a'. - publish: zip 1.37 answers a miss or a refused method with an RFC 9457 problem document, and /mcp with plain application/json, protocol 2026-07-28 and the jsonrpc member first. - wiring: the console catch-all is registered through app.Raw(zip.MethodAll, "/*", …) now, so the order check finds it by its "/*" path, not by the verb. Co-authored-by: Hanzo Dev <[email protected]>
375 lines
14 KiB
Go
375 lines
14 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// What publish is FOR, verified the only way this defect was ever visible: by
|
|
// the BODY of a request to the router, not by a status code.
|
|
//
|
|
// The conversion shipped 319 typed operations that no binary linked. Every proof
|
|
// it had was taken inside a test process that imported the table directly, which
|
|
// is exactly the thing that cannot fail: an uncalled package-level func is legal
|
|
// Go, so the package built, the route arithmetic added up, and the server the
|
|
// image runs published nothing. Status codes could not see it either — /version
|
|
// answers 200 with or without the table, because the service's own registration
|
|
// answers it.
|
|
//
|
|
// So this test drives the SAME function the server calls, over the SAME router
|
|
// type it serves, and reads the document out of it.
|
|
|
|
// documentOf fetches the OpenAPI document off a router the way a caller does.
|
|
func documentOf(t *testing.T, app *zip.App) map[string]any {
|
|
t.Helper()
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, zip.SpecPath, nil))
|
|
if err != nil {
|
|
t.Fatalf("GET %s: %v", zip.SpecPath, err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("GET %s status=%d, want 200 — the document answers on no port", zip.SpecPath, resp.StatusCode)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
|
|
t.Fatalf("GET %s content-type=%q, want application/json", zip.SpecPath, ct)
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("read body: %v", err)
|
|
}
|
|
doc := map[string]any{}
|
|
if err := json.Unmarshal(body, &doc); err != nil {
|
|
t.Fatalf("the document is not JSON: %v — body: %.120s", err, body)
|
|
}
|
|
return doc
|
|
}
|
|
|
|
// operations counts the method+path pairs the document publishes.
|
|
func operations(t *testing.T, doc map[string]any) int {
|
|
t.Helper()
|
|
paths, ok := doc["paths"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("document paths has type %T", doc["paths"])
|
|
}
|
|
n := 0
|
|
for path, raw := range paths {
|
|
item, ok := raw.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("document path %s has type %T", path, raw)
|
|
}
|
|
for method := range item {
|
|
switch method {
|
|
case "get", "post", "put", "patch", "delete":
|
|
n++
|
|
}
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// mcpTools counts the tools the router offers, read the way an MCP client reads
|
|
// them: a tools/list over the served MCP endpoint.
|
|
//
|
|
// It used to be len(app.MCPTools()) — an in-process accessor on the app the test
|
|
// had just built, which is the shape of proof this file exists to reject. The
|
|
// declaration lives on an app of its own now (see publish), so that accessor
|
|
// answers 0 on the serving app whether or not the tool surface is reachable, and
|
|
// a count taken off the declaration object instead would prove only that the
|
|
// object knows its own ops. Asking the ROUTER is what proves a caller can reach
|
|
// them.
|
|
func mcpTools(t *testing.T, app *zip.App) int {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp",
|
|
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("POST /mcp: %v", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("POST /mcp status=%d, want 200 — the tool surface answers on no port", resp.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("read body: %v", err)
|
|
}
|
|
var out struct {
|
|
Result struct {
|
|
Tools []map[string]any `json:"tools"`
|
|
} `json:"result"`
|
|
}
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
t.Fatalf("tools/list is not JSON-RPC: %v — body: %.120s", err, body)
|
|
}
|
|
return len(out.Result.Tools)
|
|
}
|
|
|
|
// THE PAYOFF, on the router the binary serves. 319 is the same count
|
|
// routes_test.go pins on the table itself; asserting it HERE is what makes the
|
|
// two the same 319 rather than two numbers that happen to agree.
|
|
func TestPublishServesTheDocument(t *testing.T) {
|
|
app := zip.New(zip.Config{DisableStartupMessage: true})
|
|
if err := publish(app); err != nil {
|
|
t.Fatalf("publish: %v", err)
|
|
}
|
|
|
|
if got := operations(t, documentOf(t, app)); got != 319 {
|
|
t.Fatalf("the served document publishes %d operations, want 319", got)
|
|
}
|
|
if got := mcpTools(t, app); got != 319 {
|
|
t.Fatalf("the served router offers %d MCP tools, want 319", got)
|
|
}
|
|
}
|
|
|
|
// The three public roots this service answers on — two faces and the ingest
|
|
// endpoint. Named here so the assertion and the message cannot disagree about what
|
|
// "o11y's surface" means.
|
|
const (
|
|
o11yRootPath = "/v1/o11y"
|
|
sentinelRootPath = "/v1/o11y/sentinel"
|
|
eventRootPath = "/v1/event"
|
|
)
|
|
|
|
// Every operation the document publishes is o11y's own surface. A host that
|
|
// mounts this table is publishing these paths under its own name, so a path that
|
|
// is not ours would be this service claiming someone else's address.
|
|
func TestPublishedDocumentIsO11ysSurface(t *testing.T) {
|
|
app := zip.New(zip.Config{DisableStartupMessage: true})
|
|
if err := publish(app); err != nil {
|
|
t.Fatalf("publish: %v", err)
|
|
}
|
|
|
|
paths, ok := documentOf(t, app)["paths"].(map[string]any)
|
|
if !ok {
|
|
t.Fatal("document has no paths")
|
|
}
|
|
for path := range paths {
|
|
// Prefix, not a fixed slice: the roots are different lengths, so an index
|
|
// is a second place the root's spelling lives and it goes stale the moment
|
|
// one of them is renamed.
|
|
if !strings.HasPrefix(path, o11yRootPath) && !strings.HasPrefix(path, sentinelRootPath) &&
|
|
!strings.HasPrefix(path, eventRootPath) {
|
|
t.Errorf("the document publishes %s, which is not o11y's surface", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
// THE FIVE CONTROL PATHS, BYTE FOR BYTE.
|
|
//
|
|
// publish's claim is that routing to the declaration app is "the same decision it
|
|
// would make on its own listener". That is a claim about bytes, so it is checked
|
|
// as one: every probe below is asked of the serving router AND of a declaration
|
|
// app of its own, and the two answers must be identical — status, Content-Type,
|
|
// the headers named here, and the body.
|
|
//
|
|
// The literal columns are what keeps the comparison from being circular. Two
|
|
// routers that both stopped answering would agree with each other; a status, a
|
|
// content type and, where the answer is short enough to write down, the bytes
|
|
// themselves are read off the ROW rather than off the other router.
|
|
type answer struct {
|
|
method string
|
|
path string
|
|
body string
|
|
|
|
status int
|
|
ctype string
|
|
// allow is the Allow header, set only where the router refuses the method.
|
|
allow string
|
|
// bytes is the whole body, for the answers short enough to state. The long
|
|
// ones — the document, the tool list, the plugin declaration — are pinned by
|
|
// the comparison with the declaration app instead.
|
|
bytes string
|
|
}
|
|
|
|
var answers = []answer{
|
|
{method: http.MethodGet, path: zip.SpecPath, status: 200, ctype: "application/json"},
|
|
{method: http.MethodGet, path: zip.DocsPath, status: 200, ctype: "text/html; charset=utf-8"},
|
|
// Fiber matches with a trailing slash and answers HEAD off the GET route, so
|
|
// both reach the same handler as /docs. Registered once, answering three ways.
|
|
{method: http.MethodGet, path: zip.DocsPath + "/", status: 200, ctype: "text/html; charset=utf-8"},
|
|
{method: http.MethodHead, path: zip.DocsPath, status: 200, ctype: "text/html; charset=utf-8", bytes: ""},
|
|
{method: http.MethodGet, path: zip.PluginPath, status: 200, ctype: "application/json; charset=utf-8"},
|
|
{
|
|
method: http.MethodPost, path: mcpPath, body: `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`,
|
|
status: 200, ctype: "application/json",
|
|
},
|
|
{
|
|
method: http.MethodPost, path: mcpPath, body: `{"jsonrpc":"2.0","id":2,"method":"initialize"}`,
|
|
status: 200, ctype: "application/json",
|
|
bytes: `{"jsonrpc":"2.0","id":2,"result":{"capabilities":{"tools":{"listChanged":false}},` +
|
|
`"protocolVersion":"2026-07-28","serverInfo":{"name":"o11y","version":""}}}`,
|
|
},
|
|
// The call plane names the op in the path and answers in ZAP, refusal
|
|
// included — application/json is the boundary encoding, and this plane is not
|
|
// the boundary.
|
|
{
|
|
method: http.MethodPost, path: zip.CallPath + "nope", body: `{}`,
|
|
status: 404, ctype: "application/zap",
|
|
bytes: "ZAP\x00\x01\x00\x00\x00\x10\x00\x00\x008\x00\x00\x00\x94\x01\x00\x00" +
|
|
"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\b\x00\x00\x00\x10\x00\x00\x00unknown op: nope",
|
|
},
|
|
|
|
// A MISS IS THE ROUTER'S OWN, and it does not look like net/http's. There is
|
|
// no path cleaning in front of these: a dot segment, a bare "." segment and a
|
|
// doubled slash are all matched literally and all miss, where an http.ServeMux
|
|
// would have answered 301 to the cleaned form before matching anything. The
|
|
// body is an RFC 9457 problem document, not "404 page not found", and no
|
|
// X-Content-Type-Options rides with it (see nosniffIsNotOurs).
|
|
{method: http.MethodPost, path: zip.CallPath, body: `{}`, status: 404, ctype: problemType, bytes: missBody},
|
|
{method: http.MethodGet, path: "/docs/../docs", status: 404, ctype: problemType, bytes: missBody},
|
|
{method: http.MethodGet, path: "//docs", status: 404, ctype: problemType, bytes: missBody},
|
|
{method: http.MethodGet, path: "/./docs", status: 404, ctype: problemType, bytes: missBody},
|
|
{method: http.MethodGet, path: zip.SpecPath + "/extra", status: 404, ctype: problemType, bytes: missBody},
|
|
{method: http.MethodGet, path: "/missing", status: 404, ctype: problemType, bytes: missBody},
|
|
{
|
|
method: http.MethodPost, path: zip.DocsPath,
|
|
status: 405, ctype: problemType, allow: "GET, HEAD",
|
|
bytes: `{"detail":"Method Not Allowed","status":405,"title":"Method Not Allowed","type":"about:blank"}`,
|
|
},
|
|
}
|
|
|
|
const (
|
|
problemType = "application/problem+json"
|
|
missBody = `{"detail":"Not Found","status":404,"title":"Not Found","type":"about:blank"}`
|
|
)
|
|
|
|
// ask puts one probe to a router and returns the whole answer.
|
|
func (a answer) ask(t *testing.T, app *zip.App) (*http.Response, []byte) {
|
|
t.Helper()
|
|
var body io.Reader
|
|
if a.body != "" {
|
|
body = strings.NewReader(a.body)
|
|
}
|
|
req := httptest.NewRequest(a.method, a.path, body)
|
|
if a.body != "" {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("%s %s: %v", a.method, a.path, err)
|
|
}
|
|
read, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("%s %s: read body: %v", a.method, a.path, err)
|
|
}
|
|
return resp, read
|
|
}
|
|
|
|
func TestPublishServesTheDeclarationVerbatim(t *testing.T) {
|
|
app := zip.New(zip.Config{DisableStartupMessage: true})
|
|
if err := publish(app); err != nil {
|
|
t.Fatalf("publish: %v", err)
|
|
}
|
|
// A declaration of its own, to answer the same probes on its own router.
|
|
own, err := declaration()
|
|
if err != nil {
|
|
t.Fatalf("declaration: %v", err)
|
|
}
|
|
|
|
for _, a := range answers {
|
|
at := a.method + " " + a.path
|
|
resp, got := a.ask(t, app)
|
|
|
|
if resp.StatusCode != a.status {
|
|
t.Errorf("%s status=%d, want %d", at, resp.StatusCode, a.status)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); ct != a.ctype {
|
|
t.Errorf("%s content-type=%q, want %q", at, ct, a.ctype)
|
|
}
|
|
if allow := resp.Header.Get("Allow"); allow != a.allow {
|
|
t.Errorf("%s allow=%q, want %q", at, allow, a.allow)
|
|
}
|
|
// net/http writes this beside every http.Error; a zip answer carries it
|
|
// only when a handler sets it, and none of these does.
|
|
if nosniff := resp.Header.Get("X-Content-Type-Options"); nosniff != "" {
|
|
t.Errorf("%s x-content-type-options=%q, want none", at, nosniff)
|
|
}
|
|
if a.bytes != "" || a.method == http.MethodHead {
|
|
if string(got) != a.bytes {
|
|
t.Errorf("%s body=%q, want %q", at, got, a.bytes)
|
|
}
|
|
}
|
|
|
|
mine, want := a.ask(t, own)
|
|
if resp.StatusCode != mine.StatusCode {
|
|
t.Errorf("%s status=%d on the serving router, %d on the declaration's own",
|
|
at, resp.StatusCode, mine.StatusCode)
|
|
}
|
|
if ct, mct := resp.Header.Get("Content-Type"), mine.Header.Get("Content-Type"); ct != mct {
|
|
t.Errorf("%s content-type=%q on the serving router, %q on the declaration's own", at, ct, mct)
|
|
}
|
|
if !bytes.Equal(got, want) {
|
|
t.Errorf("%s answers %d bytes, the declaration's own router %d — they must be the same bytes",
|
|
at, len(got), len(want))
|
|
}
|
|
}
|
|
}
|
|
|
|
// WHY THE DECLARATION IS DISPATCHED TO AND NOT COMPOSED IN.
|
|
//
|
|
// app.Use(d) would read the declaration's entries as if they had been written on
|
|
// the serving app, which is exactly what must not happen: both name the same 367
|
|
// addresses, and Build refuses a program where two definitions claim one address.
|
|
// The refusal names the address and both claimants, so the reason this is a
|
|
// dispatch is measurable rather than asserted in a comment.
|
|
func TestTheDeclarationCannotBeComposedIn(t *testing.T) {
|
|
app := zip.New(zip.Config{AppName: "o11y", DisableStartupMessage: true})
|
|
const taken = "/v1/o11y/logs"
|
|
app.Raw(http.MethodGet, taken, func(c *zip.Ctx) error { return c.NoContent(http.StatusOK) })
|
|
|
|
d, err := declaration()
|
|
if err != nil {
|
|
t.Fatalf("declaration: %v", err)
|
|
}
|
|
app.Use(d)
|
|
|
|
err = app.Build()
|
|
if err == nil {
|
|
t.Fatal("composing the declaration into the serving app built cleanly; one address now has two definitions")
|
|
}
|
|
if !strings.Contains(err.Error(), "GET "+taken) {
|
|
t.Fatalf("Build refused with %q, which does not name %s", err, taken)
|
|
}
|
|
}
|
|
|
|
// The declaration does NOT stand in front of the implementation. publish is
|
|
// called after the service's own registration precisely so the handler that has
|
|
// always answered a path still answers it — a typed op in front would put every
|
|
// answer through a buffered relay round-trip, and an unbounded stream (livetail,
|
|
// the chunked export) does not survive being buffered.
|
|
//
|
|
// Registration order is invisible to the compiler and to every test that mounts
|
|
// only one of the two, so it is pinned here: an earlier registration wins, and
|
|
// publish's routes are the later ones.
|
|
func TestServiceRoutesOutrankTheDeclaration(t *testing.T) {
|
|
app := zip.New(zip.Config{DisableStartupMessage: true})
|
|
|
|
const path = "/v1/o11y/logs"
|
|
served := false
|
|
app.Raw(http.MethodGet, path, zip.AdaptNetHTTP(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
served = true
|
|
w.WriteHeader(http.StatusOK)
|
|
})))
|
|
|
|
if err := publish(app); err != nil {
|
|
t.Fatalf("publish: %v", err)
|
|
}
|
|
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, path, nil))
|
|
if err != nil {
|
|
t.Fatalf("GET %s: %v", path, err)
|
|
}
|
|
if !served {
|
|
t.Fatalf("the declaration answered %s; the service's own handler must", path)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("GET %s status=%d, want 200", path, resp.StatusCode)
|
|
}
|
|
}
|