go-loose/internal/server/roles_test.go

178 lines
6.3 KiB
Go
Raw Normal View History

2026-10-04 16:57:30 +02:00
package server
import (
"bytes"
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path"
"strings"
"testing"
"github.com/danyel/go-loose/internal/config"
"github.com/danyel/go-loose/internal/session"
"github.com/danyel/go-loose/web"
)
// withClaims injects a management session the way requireSession would, so a
// handler can be called directly without standing up a session manager.
func withClaims(request *http.Request, claims session.Claims) context.Context {
return context.WithValue(request.Context(), claimsKey, claims)
}
func TestRolesPageIsEmbedded(t *testing.T) {
content, err := web.Files.ReadFile("roles.html")
if err != nil {
t.Fatalf("roles.html must be embedded: %v", err)
}
for _, marker := range []string{
"role-list", "role-form", "permission-list", "tenant-context", "/assets/roles.js",
} {
if !bytes.Contains(content, []byte(marker)) {
t.Errorf("roles.html is missing %q", marker)
}
}
}
// TestRolesScriptIsServed guards the asset allowlist: roles.js is a new file, and
// the handler serves assets by exact name rather than by pattern.
func TestRolesScriptIsServed(t *testing.T) {
server := profileTestServer()
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/assets/roles.js", nil)
request.SetPathValue("name", "roles.js")
response := httptest.NewRecorder()
server.asset(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", response.Code, http.StatusOK)
}
if !strings.Contains(response.Body.String(), "/api/v1/roles") {
t.Error("roles.js does not call the role endpoints")
}
}
func TestAssetAllowlistRejectsUnknownFiles(t *testing.T) {
server := profileTestServer()
for _, name := range []string{"admin.js", "roles.html", "app.js.map", "secret.env"} {
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/assets/"+name, nil)
request.SetPathValue("name", name)
response := httptest.NewRecorder()
server.asset(response, request)
if response.Code != http.StatusNotFound {
t.Errorf("asset(%q) status = %d, want %d", name, response.Code, http.StatusNotFound)
}
}
}
// TestAssetTraversalCollapsesToTheAllowlistedName documents that a traversal
// attempt cannot escape the allowlist: path.Base reduces it to the bare file
// name, which is then matched against the list like any other request.
func TestAssetTraversalCollapsesToTheAllowlistedName(t *testing.T) {
server := profileTestServer()
for _, name := range []string{"../roles.js", "../../etc/passwd", "..%2froles.js"} {
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/assets/"+name, nil)
request.SetPathValue("name", name)
response := httptest.NewRecorder()
server.asset(response, request)
served := path.Base(name)
if served == "roles.js" {
if response.Code != http.StatusOK {
t.Errorf("asset(%q) status = %d, want the allowlisted file to serve", name, response.Code)
}
continue
}
if response.Code != http.StatusNotFound {
t.Errorf("asset(%q) status = %d, want %d", name, response.Code, http.StatusNotFound)
}
}
}
func TestCreateRoleRejectsMalformedInputBeforeTouchingTheStore(t *testing.T) {
cases := []struct {
name string
body string
}{
{"missing slug", `{"name":"Role","permissions":[]}`},
{"uppercase slug", `{"slug":"Release Manager","name":"Role","permissions":[]}`},
{"slug starting with a dash", `{"slug":"-role","name":"Role","permissions":[]}`},
{"empty name", `{"slug":"role","name":" ","permissions":[]}`},
{"unknown permission", `{"slug":"role","name":"Role","permissions":["superpower"]}`},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
server := profileTestServer()
request := httptest.NewRequest(http.MethodPost, "https://auth.example.test/api/v1/roles",
strings.NewReader(testCase.body))
request.SetPathValue("id", "")
request = request.WithContext(withClaims(request, session.Claims{UserID: "user-1", Management: true}))
response := httptest.NewRecorder()
server.createRole(response, request)
// The store would need a database, so a 400 proves validation ran first.
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d (body %s)", response.Code, http.StatusBadRequest, response.Body)
}
})
}
}
func TestListRolesRequiresATenant(t *testing.T) {
server := profileTestServer()
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/api/v1/roles", nil)
request = request.WithContext(withClaims(request, session.Claims{UserID: "user-1", Management: true}))
response := httptest.NewRecorder()
server.listRoles(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", response.Code, http.StatusBadRequest)
}
if !strings.Contains(response.Body.String(), "tenant_id") {
t.Errorf("body = %s, want it to name the missing parameter", response.Body)
}
}
// TestRoleRoutesAreRegistered documents the two wrappers the role routes rely
// on. The server under test is not installed, so requireSession redirects every
// session-guarded route to the installer; an unregistered route would answer 404
// instead, which is what makes this a registration check.
func TestRoleRoutesAreRegistered(t *testing.T) {
// Handler() wraps every route in the request logger, so this server needs a
// logger where the profile tests, which call handlers directly, do not.
server := &Server{
cfg: config.Config{BaseURL: "https://auth.example.test"},
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
handler := server.Handler()
cases := []struct {
method, path string
}{
{http.MethodGet, "/api/v1/roles?tenant_id=t"},
{http.MethodPost, "/api/v1/roles"},
{http.MethodPut, "/api/v1/roles/abc"},
{http.MethodDelete, "/api/v1/roles/abc"},
{http.MethodGet, "/roles"},
}
for _, testCase := range cases {
t.Run(testCase.method+" "+testCase.path, func(t *testing.T) {
request := httptest.NewRequest(testCase.method, "https://auth.example.test"+testCase.path, nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want %d from requireSession", response.Code, http.StatusSeeOther)
}
if location := response.Header().Get("Location"); location != "/install" {
t.Fatalf("location = %q, want /install", location)
}
})
}
}