go-loose/internal/server/roles_test.go
Daniel Noulet c7c81fb65c feat: account menu, database roles, and single logout
Bundles the console work from the current branch: the header account
control, database-backed roles and permissions, and signing out across
the applications that share this sign-in.

The account control replaces a separate identity chip and a separate
sign-out button, which meant the header carried two controls describing
one fact. web/account.js is mounted into every page by a placeholder and
owns the keyboard handling, the outside-click behaviour and the sign-out
form once. It also fills the trigger's name, which the previous markup
left empty.

Signing out revokes the hosted-login bearer sessions this installation
issued, then redirects to the provider's end_session_endpoint when the
discovery document advertises one. Applications that authenticate
against this installation therefore lose their session too; one pointed
straight at an identity provider is out of its reach and is unchanged.

Roles and permissions become rows rather than a closed enum, so a tenant
can extend the catalog without a migration, and a role can no longer be
granted a capability that is not in it. requireSameOrigin now compares
against the origin the request arrived on instead of the configured base
URL, which defaulted to http://localhost:8080 and so rejected every
write in a deployment that never set it.

Two bugs found while verifying in a browser:

  - A profile picture never rendered. avatarURL built an absolute URL
    from the configured base URL, and the page's own img-src 'self'
    blocked it. The console's own pages now take a root-relative path;
    only /connect/userinfo, read from another origin, needs an absolute
    one, and it follows the request.
  - absoluteURL preferred the configuration over the request while its
    comment claimed the opposite, which also misdirected the logout
    redirect.

Card layout: the footer was one two-column row shared between a label
and a forty-character identifier, so the identifier took the width and
crushed its label into three stacked words. Each pair is a line now, the
value truncates with the full text in the title, and cards align their
footers across a row.
2026-10-05 19:18:07 +02:00

179 lines
6.4 KiB
Go

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":[]}`},
}
// Permission names are not checked here: the catalog is a table, so an unknown
// name can only be caught by the store, which reports it as a bad request.
// See TestARoleCannotGrantAPermissionThatIsNotInTheCatalog.
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)
}
})
}
}