Files
Felis/internal/api/internal_callers_test.go
T

160 lines
6.3 KiB
Go

package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func callerTokensForTest() CallerTokens {
return CallerTokens{
CallerVelocity: "tok-velocity",
CallerLimbo: "tok-limbo",
CallerBuild: "tok-build",
CallerOps: "tok-ops",
}
}
func bearer(tok string) map[string]string {
return map[string]string{"Authorization": "Bearer " + tok, "Content-Type": "application/json"}
}
// Each token answers with its own caller, and nothing else gets in.
func TestCallerTokensNameTheCaller(t *testing.T) {
c := callerTokensForTest()
for tok, want := range map[string]Caller{
"tok-velocity": CallerVelocity,
"tok-limbo": CallerLimbo,
"tok-build": CallerBuild,
"tok-ops": CallerOps,
} {
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Authorization", "Bearer "+tok)
got, err := c.Authenticate(r)
if err != nil || got != want {
t.Errorf("%s: got (%q, %v), want %q", tok, got, err, want)
}
}
for _, header := range []string{"", "Bearer tok-velocityX", "Bearer tok-veloci", "Basic tok-ops"} {
r := httptest.NewRequest("GET", "/", nil)
if header != "" {
r.Header.Set("Authorization", header)
}
if got, err := c.Authenticate(r); err == nil {
t.Errorf("%q authenticated as %q", header, got)
}
}
// A caller whose Secret is missing has an empty token; that must not turn into
// "any empty-ish credential passes".
partial := CallerTokens{CallerVelocity: "tok-velocity", CallerBuild: ""}
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Authorization", "Bearer ")
if got, err := partial.Authenticate(r); err == nil {
t.Fatalf("blank bearer authenticated as %q", got)
}
}
func TestNewCallerTokensRefusesAmbiguousSets(t *testing.T) {
if _, err := NewCallerTokens(map[Caller]string{CallerVelocity: "a", CallerLimbo: "b", CallerBuild: "", CallerOps: "c"}); err != nil {
t.Fatalf("distinct tokens refused: %v", err)
}
_, err := NewCallerTokens(map[Caller]string{CallerVelocity: "same", CallerBuild: "same"})
if err == nil || !strings.Contains(err.Error(), "same value") {
t.Fatalf("shared value: err = %v, want a same-value refusal", err)
}
}
// The caller scopes the gap asked for, spelled out rather than read back from the
// route table: the build token reads a submission's context and nothing else, only
// the proxy and the login gate mint link codes, only the proxy approves an
// op-login, and only the on-node console asks for a break-glass backup.
func TestInternalRoutesServeOnlyTheirCallers(t *testing.T) {
a := newTestAPI(newFakeRepo(), newFakeCluster())
a.Internal = callerTokensForTest()
h := a.InternalHandler()
cases := []struct {
method, path string
allowed []Caller
}{
{"GET", "/api/v1/servers", []Caller{CallerVelocity}},
{"GET", "/api/v1/internal/submissions/sub-1/context", []Caller{CallerBuild}},
{"POST", "/api/v1/internal/account/link/code", []Caller{CallerVelocity, CallerLimbo}},
{"GET", "/api/v1/internal/account/link/status/00000000-0000-0000-0000-000000000001", []Caller{CallerVelocity, CallerLimbo}},
{"GET", "/api/v1/internal/player/blacklist/00000000-0000-0000-0000-000000000001", []Caller{CallerVelocity, CallerLimbo}},
{"POST", "/api/v1/internal/op-login/req-1/approve", []Caller{CallerVelocity}},
{"GET", "/api/v1/internal/op-login/pending", []Caller{CallerVelocity}},
{"GET", "/api/v1/internal/op-login/req-1", []Caller{CallerVelocity}},
{"POST", "/api/v1/internal/account/migrate/start", []Caller{CallerVelocity}},
{"POST", "/api/v1/internal/player/reclaim", []Caller{CallerVelocity}},
{"POST", "/api/v1/internal/servers/survival/wake", []Caller{CallerVelocity}},
{"POST", "/api/v1/internal/servers/survival/claim", []Caller{CallerVelocity}},
{"POST", "/api/v1/internal/servers/survival/backup", []Caller{CallerOps}},
}
tokens := map[Caller]string{CallerVelocity: "tok-velocity", CallerLimbo: "tok-limbo", CallerBuild: "tok-build", CallerOps: "tok-ops"}
for _, tc := range cases {
for caller, tok := range tokens {
allowed := false
for _, c := range tc.allowed {
allowed = allowed || c == caller
}
w := do(h, tc.method, tc.path, "{}", bearer(tok))
refused := w.Code == http.StatusForbidden && decodeErr(t, w) == "wrong_caller"
if allowed && (refused || w.Code == http.StatusUnauthorized) {
t.Errorf("%s %s as %s: refused (%d %s), want it served", tc.method, tc.path, caller, w.Code, w.Body.String())
}
if !allowed && !refused {
t.Errorf("%s %s as %s: got %d %s, want 403 wrong_caller", tc.method, tc.path, caller, w.Code, w.Body.String())
}
}
}
}
// The audit names the caller whose token asked for the action.
func TestInternalAuditNamesTheCaller(t *testing.T) {
repo := newFakeRepo()
a := newTestAPI(repo, newFakeCluster())
a.Internal = callerTokensForTest()
r := httptest.NewRequest("POST", "/", nil)
if got := internalSource(r); got != "internal" {
t.Fatalf("no caller: source = %q, want internal", got)
}
var seen string
probe := a.requireInternal(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = internalSource(r)
}))
r.Header.Set("Authorization", "Bearer tok-limbo")
probe.ServeHTTP(httptest.NewRecorder(), r)
if seen != "internal:limbo" {
t.Fatalf("source = %q, want internal:limbo", seen)
}
}
// A route added to the internal table without saying who calls it would be open
// to every token; the face refuses to build instead. Callers on an external route
// would mean nothing, so that is refused too.
func TestBuildFaceRequiresCallersOnInternalRoutes(t *testing.T) {
a := newTestAPI(newFakeRepo(), newFakeCluster())
noop := func(w http.ResponseWriter, r *http.Request) {}
pass := func(h http.Handler) http.Handler { return h }
mustPanic := func(name string, fn func()) {
t.Helper()
defer func() {
if recover() == nil {
t.Errorf("%s: built without complaint", name)
}
}()
fn()
}
mustPanic("internal route without callers", func() {
a.buildFace("internal", []apiRoute{{Method: "GET", Pattern: "/api/v1/internal/x", h: noop}}, pass)
})
mustPanic("external route with callers", func() {
a.buildFace("external", []apiRoute{{Method: "GET", Pattern: "/api/v1/x", Callers: []Caller{CallerOps}, h: noop}}, pass)
})
// Public internal routes (probes, hasJoined) carry no token and list no callers.
a.buildFace("internal", []apiRoute{{Method: "GET", Pattern: "/readyz", Public: true, h: noop}}, pass)
}