d62cc08b2e
gates / gates (push) Successful in 6m44s
- Configuration → Access tokens: named, scoped (group read/write), expiring (≤365 d), shown once, stored SHA-256 only, every use logged by name. Bearer fhp_… on the web surface only; /api/v1 refuses it. - Route table = the code's single source (web/routes.go), pinned to ServeHTTP's switch; session-only: secrets shown/minted, deletes/resets, password, tokens. - R-952 closed: /api/v1 route table names each caller, pinned to the switch; no-credential refusal test. - Fail-open fixed: an empty operator password served the UI open (main skipped RequireAuth). Live hub never open. - 05 §17, 04 §6.2, 13, 09 §3 251; register 155→157 (+R-1054, R-1055, R-1056, −R-952); STATUS. - website: prowlarr-logo.svg (byte copy from felhom.eu-media) — the site gate refused every push. Not deployed — waits for the operator's yes. Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
553 lines
20 KiB
Go
553 lines
20 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// Operator access tokens (hub v0.152.0, `05` §17). Red-proofs are recorded in the hub CHANGELOG v0.152.0 entry and
|
|
// felhom.eu/REPORT-hub-tokens.md.
|
|
|
|
// tokenServer is a hub web server with an operator password, a log buffer, and its DB path.
|
|
func tokenServer(t *testing.T) (*Server, *store.Store, *bytes.Buffer, string) {
|
|
t.Helper()
|
|
dbPath := filepath.Join(t.TempDir(), "t.db")
|
|
st, err := store.New(dbPath, log.New(io.Discard, "", 0))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
hash, _ := bcrypt.GenerateFromPassword([]byte("operator-pw"), bcrypt.MinCost)
|
|
var buf bytes.Buffer
|
|
s := New(st, string(hash), "", "test", 30*time.Minute, log.New(&buf, "", 0))
|
|
return s, st, &buf, dbPath
|
|
}
|
|
|
|
// mintToken stores a token with scopes and returns its plain value.
|
|
func mintToken(t *testing.T, st *store.Store, name string, expires time.Time, scopes ...string) string {
|
|
t.Helper()
|
|
plain, err := newPlainToken()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.CreateAPIToken(name, plain, scopes, "test", expires); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return plain
|
|
}
|
|
|
|
func tokenReq(method, path, plain string, form url.Values) *http.Request {
|
|
var body io.Reader
|
|
if form != nil {
|
|
body = strings.NewReader(form.Encode())
|
|
}
|
|
r := httptest.NewRequest(method, path, body)
|
|
if form != nil {
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
}
|
|
if plain != "" {
|
|
r.Header.Set("Authorization", "Bearer "+plain)
|
|
}
|
|
return r
|
|
}
|
|
|
|
func serve(s *Server, r *http.Request) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
// --- the route table is the switch -------------------------------------------------------------------------
|
|
|
|
// switchRoutes reads ServeHTTP's route switch from server.go's syntax tree: one routeEntry (match only) per
|
|
// disjunct of each case, in order.
|
|
func switchRoutes(t *testing.T) []routeEntry {
|
|
t.Helper()
|
|
f, err := parser.ParseFile(token.NewFileSet(), "server.go", nil, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var sw *ast.SwitchStmt
|
|
for _, d := range f.Decls {
|
|
fd, ok := d.(*ast.FuncDecl)
|
|
if !ok || fd.Name.Name != "ServeHTTP" || fd.Recv == nil {
|
|
continue
|
|
}
|
|
for _, st := range fd.Body.List {
|
|
if s, ok := st.(*ast.SwitchStmt); ok && s.Tag == nil {
|
|
sw = s
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if sw == nil {
|
|
t.Fatal("ServeHTTP route switch not found in server.go")
|
|
}
|
|
var out []routeEntry
|
|
var disjuncts func(e ast.Expr) []ast.Expr
|
|
disjuncts = func(e ast.Expr) []ast.Expr {
|
|
if b, ok := e.(*ast.BinaryExpr); ok && b.Op == token.LOR {
|
|
return append(disjuncts(b.X), disjuncts(b.Y)...)
|
|
}
|
|
return []ast.Expr{e}
|
|
}
|
|
var conjuncts func(e ast.Expr) []ast.Expr
|
|
conjuncts = func(e ast.Expr) []ast.Expr {
|
|
if b, ok := e.(*ast.BinaryExpr); ok && b.Op == token.LAND {
|
|
return append(conjuncts(b.X), conjuncts(b.Y)...)
|
|
}
|
|
return []ast.Expr{e}
|
|
}
|
|
lit := func(e ast.Expr) string {
|
|
bl, ok := e.(*ast.BasicLit)
|
|
if !ok {
|
|
t.Fatalf("route predicate argument is not a string literal: %#v", e)
|
|
}
|
|
v, _ := strconv.Unquote(bl.Value)
|
|
return v
|
|
}
|
|
for _, c := range sw.Body.List {
|
|
cc := c.(*ast.CaseClause)
|
|
for _, e := range cc.List {
|
|
for _, d := range disjuncts(e) {
|
|
var re routeEntry
|
|
for _, p := range conjuncts(d) {
|
|
switch x := p.(type) {
|
|
case *ast.BinaryExpr:
|
|
if id, ok := x.X.(*ast.Ident); !ok || id.Name != "path" || x.Op != token.EQL {
|
|
t.Fatalf("unrecognised route predicate at %v", x.Pos())
|
|
}
|
|
re.Exact = lit(x.Y)
|
|
case *ast.CallExpr:
|
|
name := ""
|
|
switch fn := x.Fun.(type) {
|
|
case *ast.SelectorExpr:
|
|
name = fn.Sel.Name
|
|
case *ast.Ident:
|
|
name = fn.Name
|
|
}
|
|
switch name {
|
|
case "HasPrefix":
|
|
re.Prefix = lit(x.Args[1])
|
|
case "HasSuffix":
|
|
re.Suffix = lit(x.Args[1])
|
|
case "Contains":
|
|
re.Contains = lit(x.Args[1])
|
|
case "isPublicBindPath":
|
|
re.BindFn = true
|
|
default:
|
|
t.Fatalf("unrecognised route predicate %s at %v", name, x.Pos())
|
|
}
|
|
default:
|
|
t.Fatalf("unrecognised route predicate at %v", p.Pos())
|
|
}
|
|
}
|
|
out = append(out, re)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestRouteTable_MatchesServeHTTPSwitch is R-952 for the web side: a route added to the switch without a class in
|
|
// operatorRoutes — or a reorder that would change which handler a path reaches — fails here.
|
|
// RED-PROOF: delete the /hosts/{id}/operator-action entry from operatorRoutes → FAIL at index 21.
|
|
func TestRouteTable_MatchesServeHTTPSwitch(t *testing.T) {
|
|
sw := switchRoutes(t)
|
|
if len(sw) < 60 {
|
|
t.Fatalf("parsed only %d switch routes — the parser lost the switch", len(sw))
|
|
}
|
|
strip := func(e routeEntry) routeEntry {
|
|
return routeEntry{Exact: e.Exact, Prefix: e.Prefix, Suffix: e.Suffix, Contains: e.Contains, BindFn: e.BindFn}
|
|
}
|
|
n := len(sw)
|
|
if len(operatorRoutes) > n {
|
|
n = len(operatorRoutes)
|
|
}
|
|
for i := 0; i < n; i++ {
|
|
var a, b routeEntry
|
|
if i < len(sw) {
|
|
a = sw[i]
|
|
}
|
|
if i < len(operatorRoutes) {
|
|
b = strip(operatorRoutes[i])
|
|
}
|
|
if a != b {
|
|
t.Fatalf("route %d: switch has %+v, table has %+v — classify the route in routes.go in the switch's order", i, a, b)
|
|
}
|
|
}
|
|
}
|
|
|
|
// samplePath builds a path that entry e matches.
|
|
func samplePath(e routeEntry) string {
|
|
switch {
|
|
case e.BindFn:
|
|
return "/bind/tok"
|
|
case e.Exact != "":
|
|
return e.Exact
|
|
}
|
|
p := e.Prefix + "x1"
|
|
if e.Contains != "" {
|
|
p += e.Contains + "y1"
|
|
}
|
|
return p + e.Suffix
|
|
}
|
|
|
|
// every (method, path) pair the table names, with its class.
|
|
type routeSample struct {
|
|
method, path string
|
|
class routeClass
|
|
}
|
|
|
|
func routeSamples(t *testing.T) []routeSample {
|
|
var out []routeSample
|
|
for i, e := range operatorRoutes {
|
|
p := samplePath(e)
|
|
// The sample must reach THIS entry, or the class tested is not the class served.
|
|
for j, f := range operatorRoutes {
|
|
if f.matches(p) {
|
|
if j != i {
|
|
t.Fatalf("sample %s for entry %d reaches entry %d first", p, i, j)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
for _, m := range []string{http.MethodGet, http.MethodPost} {
|
|
out = append(out, routeSample{m, p, classifyRoute(m, p)})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestScopeGroups_EveryOfferedTickOpensARoute: a tick on the card that opens nothing would be a promise the hub
|
|
// does not keep; a group a route names but the card does not offer could never be granted.
|
|
func TestScopeGroups_EveryOfferedTickOpensARoute(t *testing.T) {
|
|
used := map[string]bool{}
|
|
for _, s := range routeSamples(t) {
|
|
if s.class.Group != "" {
|
|
used[s.class.scope()] = true
|
|
}
|
|
}
|
|
for _, g := range scopeGroups {
|
|
if g.Read && !used[g.Key+":read"] {
|
|
t.Errorf("card offers %s:read but no route needs it", g.Key)
|
|
}
|
|
if g.Write && !used[g.Key+":write"] {
|
|
t.Errorf("card offers %s:write but no route needs it", g.Key)
|
|
}
|
|
}
|
|
for sc := range used {
|
|
if !validScope(sc) && !(strings.HasSuffix(sc, ":read") && validScope(strings.TrimSuffix(sc, ":read")+":write")) {
|
|
t.Errorf("route needs %s, which the card cannot grant", sc)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- what a token may and may not do -----------------------------------------------------------------------
|
|
|
|
func allScopes() []string {
|
|
var out []string
|
|
for _, g := range scopeGroups {
|
|
if g.Read {
|
|
out = append(out, g.Key+":read")
|
|
}
|
|
if g.Write {
|
|
out = append(out, g.Key+":write")
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestToken_SessionOnlyRoutesRefuseEveryToken: a token holding EVERY scope gets 403 on every session-only route —
|
|
// the reveal, the deletes, the resets, the password, the token routes, the customer page with its passphrase.
|
|
// RED-PROOF: make tokenAllows return true for SessionOnly → FAIL on /hosts/x1/reveal-recovery-credential.
|
|
func TestToken_SessionOnlyRoutesRefuseEveryToken(t *testing.T) {
|
|
s, st, _, _ := tokenServer(t)
|
|
plain := mintToken(t, st, "all", time.Now().Add(time.Hour), allScopes()...)
|
|
n := 0
|
|
for _, rs := range routeSamples(t) {
|
|
if !rs.class.SessionOnly {
|
|
continue
|
|
}
|
|
n++
|
|
w := serve(s, tokenReq(rs.method, rs.path, plain, url.Values{}))
|
|
if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "session only") {
|
|
t.Errorf("%s %s with an all-scope token: %d %q, want 403 session only", rs.method, rs.path, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
for _, must := range []string{"/hosts/x1/reveal-recovery-credential", "/configs/x1/delete", "/configs/x1/reset",
|
|
"/hosts/x1/delete", "/configuration/password", "/configuration/tokens", "/customers/x1", "/configs/x1/preview"} {
|
|
if c := classifyRoute(http.MethodPost, must); !c.SessionOnly {
|
|
if c2 := classifyRoute(http.MethodGet, must); !c2.SessionOnly {
|
|
t.Errorf("%s must be session only", must)
|
|
}
|
|
}
|
|
}
|
|
if n < 20 {
|
|
t.Fatalf("only %d session-only samples — the table lost its classes", n)
|
|
}
|
|
}
|
|
|
|
// TestToken_NoTokenUnknownExpiredRevoked401: a token the hub does not hold, an expired one, a revoked one → 401.
|
|
// RED-PROOF: drop the Expired branch in serveWithToken → the expired case answers 200.
|
|
func TestToken_NoTokenUnknownExpiredRevoked401(t *testing.T) {
|
|
s, st, _, _ := tokenServer(t)
|
|
unknown, _ := newPlainToken()
|
|
expired := mintToken(t, st, "old", time.Now().Add(-time.Minute), "overview:read")
|
|
revoked := mintToken(t, st, "gone", time.Now().Add(time.Hour), "overview:read")
|
|
list, _ := st.ListAPITokens()
|
|
for _, tk := range list {
|
|
if tk.Name == "gone" {
|
|
if err := st.RevokeAPIToken(tk.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
for name, plain := range map[string]string{"unknown": unknown, "expired": expired, "revoked": revoked} {
|
|
w := serve(s, tokenReq(http.MethodGet, "/", plain, nil))
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("%s token: %d, want 401", name, w.Code)
|
|
}
|
|
}
|
|
// No credential at all: unchanged (browser → login page, script → 401).
|
|
r := tokenReq(http.MethodGet, "/", "", nil)
|
|
r.Header.Set("Accept", "application/json")
|
|
if w := serve(s, r); w.Code != http.StatusUnauthorized {
|
|
t.Errorf("no credential: %d, want 401", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestToken_ScopeDecides: a token without the group → 403 naming the scope; read on a write route → 403; the right
|
|
// scope → the handler runs AND its effect lands (the floor is stored), with no CSRF token needed.
|
|
// RED-PROOF: make tokenAllows ignore Write (accept group:read for a write route) → the read-token floor save lands.
|
|
func TestToken_ScopeDecides(t *testing.T) {
|
|
s, st, logs, _ := tokenServer(t)
|
|
overview := mintToken(t, st, "ov", time.Now().Add(time.Hour), "overview:read")
|
|
floorRead := mintToken(t, st, "fr", time.Now().Add(time.Hour), "overview:read", "floor:read")
|
|
cc := mintToken(t, st, "cc-releases", time.Now().Add(time.Hour), "overview:read", "floor:write")
|
|
form := url.Values{"min_controller_version": {"0.300.0"}, "min_agent": {"0.150.0"}}
|
|
|
|
w := serve(s, tokenReq(http.MethodPost, "/configuration/global-floor", overview, form))
|
|
if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "missing scope floor:write") {
|
|
t.Errorf("overview token on floor save: %d %q", w.Code, w.Body.String())
|
|
}
|
|
w = serve(s, tokenReq(http.MethodPost, "/configuration/global-floor", floorRead, form))
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("floor:read token on floor save: %d, want 403", w.Code)
|
|
}
|
|
if got := st.GetGlobalMinControllerVersion(); got == "0.300.0" {
|
|
t.Fatal("a refused token changed the floor")
|
|
}
|
|
// The vouch is not CC's: floor:write does not open it.
|
|
w = serve(s, tokenReq(http.MethodPost, "/configuration/artifacts", cc, url.Values{"agent_version": {"0.1.0"}}))
|
|
if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "missing scope vouch:write") {
|
|
t.Errorf("cc token on the vouch: %d %q", w.Code, w.Body.String())
|
|
}
|
|
// Allowed — and the consequence is real.
|
|
w = serve(s, tokenReq(http.MethodPost, "/configuration/global-floor", cc, form))
|
|
if w.Code != http.StatusSeeOther {
|
|
t.Fatalf("cc token on floor save: %d %q", w.Code, w.Body.String())
|
|
}
|
|
if got := st.GetGlobalMinControllerVersion(); got != "0.300.0" {
|
|
t.Fatalf("floor after the cc token's save = %q, want 0.300.0", got)
|
|
}
|
|
if got := st.GlobalFloorDeclaredMinAgent(); got != "0.150.0" {
|
|
t.Errorf("declared MinAgent = %q, want 0.150.0", got)
|
|
}
|
|
// write includes read: the Configuration page opens for floor:write.
|
|
if w := serve(s, tokenReq(http.MethodGet, "/configuration", cc, nil)); w.Code != http.StatusOK {
|
|
t.Errorf("cc token GET /configuration: %d", w.Code)
|
|
} else if strings.Contains(w.Body.String(), `action="/configuration/tokens"`) {
|
|
t.Error("a token-authenticated read of /configuration must not render the token card")
|
|
}
|
|
// The log names the token, never the token value.
|
|
out := logs.String()
|
|
if !strings.Contains(out, `access token "cc-releases": POST /configuration/global-floor → 303`) {
|
|
t.Errorf("no allowed-use log line naming the token:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, `access token "ov" refused: missing scope floor:write`) {
|
|
t.Errorf("no refusal log line naming the token:\n%s", out)
|
|
}
|
|
for _, p := range []string{overview, floorRead, cc} {
|
|
if strings.Contains(out, p) || strings.Contains(out, p[len(TokenPrefix):]) {
|
|
t.Fatal("a plain token reached the log")
|
|
}
|
|
}
|
|
list, _ := st.ListAPITokens()
|
|
for _, tk := range list {
|
|
if tk.Name == "cc-releases" && (tk.LastUsedAt == nil || tk.LastUsedFrom == "") {
|
|
t.Errorf("cc-releases last-used not recorded: %+v", tk)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAPIToken_StoredRowHasNoPlainToken: neither the row nor the database file holds the plain token.
|
|
// RED-PROOF: store plain instead of HashAPIToken(plain) → FAIL (the file holds it).
|
|
func TestAPIToken_StoredRowHasNoPlainToken(t *testing.T) {
|
|
_, st, _, dbPath := tokenServer(t)
|
|
plain := mintToken(t, st, "x", time.Now().Add(time.Hour), "overview:read")
|
|
st.Close()
|
|
secret := plain[len(TokenPrefix):]
|
|
for _, p := range []string{dbPath, dbPath + "-wal"} {
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if bytes.Contains(b, []byte(secret)) {
|
|
t.Fatalf("%s holds the plain token", filepath.Base(p))
|
|
}
|
|
}
|
|
// Positive control: the hash IS stored (so the scan above looked at the bytes the row lives in).
|
|
var found bool
|
|
for _, p := range []string{dbPath, dbPath + "-wal"} {
|
|
if b, err := os.ReadFile(p); err == nil && bytes.Contains(b, []byte(store.HashAPIToken(plain))) {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("positive control failed: the token hash is in neither the db nor its wal")
|
|
}
|
|
}
|
|
|
|
// TestToken_CSRFRuleUnchangedForBasic: Basic without the operator header is still refused on a POST; with it, it
|
|
// passes; a session without its form token is still refused. Only the token path skips the form token.
|
|
func TestToken_CSRFRuleUnchangedForBasic(t *testing.T) {
|
|
s, st, _, _ := tokenServer(t)
|
|
form := url.Values{"min_controller_version": {"0.301.0"}, "min_agent": {"0.150.0"}}
|
|
r := tokenReq(http.MethodPost, "/configuration/global-floor", "", form)
|
|
r.SetBasicAuth("", "operator-pw")
|
|
if w := serve(s, r); w.Code != http.StatusForbidden {
|
|
t.Errorf("Basic without %s: %d, want 403", OperatorCLIHeader, w.Code)
|
|
}
|
|
r = tokenReq(http.MethodPost, "/configuration/global-floor", "", form)
|
|
r.SetBasicAuth("", "operator-pw")
|
|
r.Header.Set(OperatorCLIHeader, "cli")
|
|
if w := serve(s, r); w.Code != http.StatusSeeOther || st.GetGlobalMinControllerVersion() != "0.301.0" {
|
|
t.Errorf("Basic with %s: %d floor=%q", OperatorCLIHeader, w.Code, st.GetGlobalMinControllerVersion())
|
|
}
|
|
}
|
|
|
|
// TestToken_CannotManageTokens: even with every scope, a token cannot create or revoke one (the route is session
|
|
// only AND the handlers check again).
|
|
func TestToken_CannotManageTokens(t *testing.T) {
|
|
s, st, _, _ := tokenServer(t)
|
|
plain := mintToken(t, st, "all", time.Now().Add(time.Hour), allScopes()...)
|
|
w := serve(s, tokenReq(http.MethodPost, "/configuration/tokens", plain, url.Values{"name": {"evil"}, "scope": {"vouch:write"}}))
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("token creating a token: %d", w.Code)
|
|
}
|
|
// The handler's own check, bypassing the table.
|
|
r := tokenReq(http.MethodPost, "/configuration/tokens", "", url.Values{"name": {"evil"}, "scope": {"vouch:write"}})
|
|
tk, _ := st.GetAPITokenByPlain(plain)
|
|
w = httptest.NewRecorder()
|
|
s.handleTokenCreate(w, r.WithContext(contextWithToken(r, tk)))
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("handleTokenCreate under a token: %d", w.Code)
|
|
}
|
|
list, _ := st.ListAPITokens()
|
|
if len(list) != 1 {
|
|
t.Fatalf("a token was created by a token: %d rows", len(list))
|
|
}
|
|
}
|
|
|
|
func contextWithToken(r *http.Request, tk *store.APIToken) context.Context {
|
|
return context.WithValue(r.Context(), tokenCtxKey{}, tk)
|
|
}
|
|
|
|
// TestTokenCreate_SessionShowsItOnce: from a session the card creates a token, shows it in this one response
|
|
// (no-store), stores only its hash, and the token works with exactly the ticked scopes.
|
|
func TestTokenCreate_SessionShowsItOnce(t *testing.T) {
|
|
s, st, logs, _ := tokenServer(t)
|
|
form := url.Values{"name": {"cc-releases"}, "scope": {"overview:read", "floor:write"}, "days": {"90"}}
|
|
r := tokenReq(http.MethodPost, "/configuration/tokens", "", form)
|
|
w := httptest.NewRecorder()
|
|
s.handleTokenCreate(w, r)
|
|
if w.Code != http.StatusOK || w.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatalf("create: %d cache=%q", w.Code, w.Header().Get("Cache-Control"))
|
|
}
|
|
body := w.Body.String()
|
|
i := strings.Index(body, TokenPrefix)
|
|
if i < 0 {
|
|
t.Fatal("the new token is not shown")
|
|
}
|
|
plain := body[i : i+len(TokenPrefix)+52]
|
|
if strings.Contains(logs.String(), plain[len(TokenPrefix):]) {
|
|
t.Fatal("the plain token reached the log")
|
|
}
|
|
tk, _ := st.GetAPITokenByPlain(plain)
|
|
if tk == nil || strings.Join(tk.Scopes, ",") != "overview:read,floor:write" {
|
|
t.Fatalf("stored token: %+v", tk)
|
|
}
|
|
if d := time.Until(tk.ExpiresAt); d < 89*24*time.Hour || d > 91*24*time.Hour {
|
|
t.Errorf("expiry %v, want ~90 days", d)
|
|
}
|
|
// A second render of the page does not show it again.
|
|
r2 := tokenReq(http.MethodGet, "/configuration", "", nil)
|
|
w2 := httptest.NewRecorder()
|
|
s.handleConfiguration(w2, r2)
|
|
if strings.Contains(w2.Body.String(), plain) {
|
|
t.Fatal("the token is shown a second time")
|
|
}
|
|
if !strings.Contains(w2.Body.String(), "cc-releases") {
|
|
t.Error("the token list does not name the token")
|
|
}
|
|
// Refusals: no scope, unknown scope, never, over a year.
|
|
for _, bad := range []url.Values{
|
|
{"name": {"a"}},
|
|
{"name": {"a"}, "scope": {"tokens:write"}},
|
|
{"name": {"a"}, "scope": {"overview:read"}, "days": {"0"}},
|
|
{"name": {"a"}, "scope": {"overview:read"}, "days": {"366"}},
|
|
{"name": {""}, "scope": {"overview:read"}},
|
|
} {
|
|
w := httptest.NewRecorder()
|
|
s.handleTokenCreate(w, tokenReq(http.MethodPost, "/configuration/tokens", "", bad))
|
|
if w.Code != http.StatusSeeOther {
|
|
t.Errorf("bad create %v: %d, want a refusal redirect", bad, w.Code)
|
|
}
|
|
}
|
|
if list, _ := st.ListAPITokens(); len(list) != 1 {
|
|
t.Errorf("refused creates stored rows: %d", len(list))
|
|
}
|
|
}
|
|
|
|
// TestRequireAuth_NoPasswordRefusesEveryOperatorRoute: with no operator password anywhere, every operator route
|
|
// answers 503 — never the page. /bind/ stays public.
|
|
// RED-PROOF: restore the old `if effectivePasswordHash() == "" { next.ServeHTTP }` → the dashboard answers 200.
|
|
func TestRequireAuth_NoPasswordRefusesEveryOperatorRoute(t *testing.T) {
|
|
st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
s := New(st, "", "", "test", 30*time.Minute, log.New(io.Discard, "", 0))
|
|
for _, rs := range routeSamples(t) {
|
|
if rs.class.Exempt {
|
|
continue
|
|
}
|
|
w := serve(s, tokenReq(rs.method, rs.path, "", url.Values{}))
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("no password: %s %s → %d, want 503", rs.method, rs.path, w.Code)
|
|
}
|
|
}
|
|
if w := serve(s, tokenReq(http.MethodGet, "/bind/sometoken", "", nil)); w.Code == http.StatusServiceUnavailable {
|
|
t.Error("the public /bind/ surface must not depend on the operator password")
|
|
}
|
|
}
|