Files
meshcore-analyzer/cmd/server/simple_post_test.go
Paul PicazoandClaude Opus 5.5 d66a219bcb feat(cors): let a UI on another origin connect, static host previews included (#2362)
## What this does

Server changes so the UI can be served from another origin, such as a
Cloudflare Pages project and its previews, against an existing backend.
It ships before the frontend, which is parked on
[`backup/static-frontend-1a`](https://github.com/ppicazo/CoreScope/tree/backup/static-frontend-1a)
and will come as its own PR.

- **Exposed headers:** allowed origins get
`Access-Control-Expose-Headers: Retry-After, X-CoreScope-Load-Status`.
The UI reads both, and cross-origin they are `null` without this.
- **Wildcard origins:** an entry may be `scheme://*.host[:port]`, such
as `https://*.myproject.pages.dev`. It matches exactly one leftmost
label (each deploy and branch preview) for both CORS and `/ws`, but not
`myproject.pages.dev` itself. The part after `*.` needs at least three
labels, so `*.pages.dev` is refused. Refused or malformed wildcards are
dropped at startup with a log line. That floor is a sanity check, not a
Public Suffix List lookup: `https://*.s3.amazonaws.com` passes and would
admit every S3 website endpoint, so a wildcard belongs only over a host
the operator controls.
- **Case:** exact entries now match case-insensitively for CORS (was
`==`), as they already did for `/ws`.
- **Test only:** pins that the three anonymous POSTs (`/api/decode`,
`/api/packets/observations`, `/api/paths/inspect`) accept a `text/plain`
JSON body, which keeps them free of a preflight.

Credentials stay off, so nothing a listed origin sends rides on cookies.
Beyond GET, a listed origin, and so every deployment under a wildcard
entry, can:

- call those three anonymous POSTs as simple requests. That predates
this PR; they compute an answer and write nothing.
`Access-Control-Allow-Methods: GET, HEAD, OPTIONS` only governs
preflighted requests.
- with `userManagement.enabled`, use the existing bearer tier for
companion linking (`/api/auth/device-token`, `/api/auth/me`,
`/api/auth/logout`, `/api/account/settings`, `/api/account/companions*`)
with POST, PUT and DELETE. Those routes need a device token in
`Authorization`, not a cookie, but a preview deployment gets them too.
On such a server, list a wildcard only if previews may use them.

## Configuring

```yaml
- CORS_ALLOWED_ORIGINS=https://existing-embed.example,https://myproject.pages.dev,https://*.myproject.pages.dev
```

The env var replaces `corsAllowedOrigins`, so keep the origins already
listed there.

## Testing

- `go test ./...`, `go vet` and `gofmt` are clean in `cmd/server`. The
new tests were written first and failed first.
- In Chromium, the static build from the backup branch, served on
`pr-1.proj.preview.localhost`, passed its split E2E (9/9, WebSocket
included) against this server.
- From the same setup, the project origin connected, a two-level
subdomain was blocked, and `X-CoreScope-Load-Status` read `ready` where
master gives `null`.

## Performance

These run per request in the CORS middleware and the `/ws` handshake,
and they allocate nothing. Same-origin requests skip them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:46:17 +01:00

59 lines
2.4 KiB
Go

package main
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
)
// A UI served from another origin (a static host listed in
// CORS_ALLOWED_ORIGINS) sends its three anonymous JSON POSTs with Content-Type
// text/plain. That keeps each one a CORS simple request: no preflight, which
// the embed CORS tier (Allow-Methods GET, HEAD, OPTIONS) would
// refuse. It works because these handlers decode the body as JSON whatever its
// Content-Type. If a handler starts insisting on application/json, this fails
// here instead of on the static host.
func TestAnonymousPostsAcceptTextPlainJSON(t *testing.T) {
_, router := setupTestServerWithAPIKey(t, "")
// The full test server is still warming the inspect graph (503), and the
// bare server path_inspect_test.go uses has no node data to search. A
// prefix the handler rejects after decoding still proves the point: a body
// it could not decode would answer "invalid JSON" instead.
inspect := newTestServerForInspect(t)
cases := []struct {
path, body string
handler http.Handler
want int
wantBody string
}{
{"/api/decode", `{"hex":"0200"}`, router, http.StatusOK, `"decoded"`},
{"/api/packets/observations", `{"hashes":["abc123"]}`, router, http.StatusOK, `"results"`},
{"/api/paths/inspect", `{"prefixes":["abc"]}`, http.HandlerFunc(inspect.handlePathInspect), http.StatusBadRequest, "even-length hex"},
}
for _, c := range cases {
t.Run(c.path, func(t *testing.T) {
post := func(contentType string) *httptest.ResponseRecorder {
req := httptest.NewRequest("POST", c.path, bytes.NewBufferString(c.body))
req.Header.Set("Content-Type", contentType)
req.Header.Set("Origin", "https://beta.corescope.example")
w := httptest.NewRecorder()
c.handler.ServeHTTP(w, req)
return w
}
asJSON := post("application/json")
asText := post("text/plain;charset=UTF-8")
if asJSON.Code != c.want || !bytes.Contains(asJSON.Body.Bytes(), []byte(c.wantBody)) {
t.Fatalf("application/json: expected %d with %s, got %d (body: %s)", c.want, c.wantBody, asJSON.Code, asJSON.Body.String())
}
if asText.Code != asJSON.Code {
t.Fatalf("text/plain: got %d, application/json got %d (body: %s)", asText.Code, asJSON.Code, asText.Body.String())
}
if asText.Body.String() != asJSON.Body.String() {
t.Fatalf("text/plain answered differently:\n text/plain: %s\n json: %s", asText.Body.String(), asJSON.Body.String())
}
})
}
}