Files
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

228 lines
7.7 KiB
Go

package auth
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"golang.org/x/oauth2"
)
func TestBeginAuthorizationUsesStateNonceAndPKCE(t *testing.T) {
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
flow, err := BeginAuthorization(oauth2.Endpoint{AuthURL: "https://auth.example/authorize"}, OIDCConfig{ClientID: "pulse", RedirectURL: "https://pulse.example/callback"}, now)
if err != nil {
t.Fatal(err)
}
parsed, err := url.Parse(flow.URL)
if err != nil {
t.Fatal(err)
}
query := parsed.Query()
for _, key := range []string{"state", "nonce", "code_challenge", "code_challenge_method"} {
if query.Get(key) == "" {
t.Fatalf("authorization URL missing %s", key)
}
}
if query.Get("state") != flow.State || query.Get("nonce") != flow.Nonce || query.Get("code_challenge_method") != "S256" {
t.Fatalf("authorization URL does not match flow: %s", flow.URL)
}
if query.Get("code_challenge") != pkceChallenge(flow.CodeVerifier) {
t.Fatal("authorization URL has incorrect PKCE challenge")
}
}
func TestValidateCallbackRejectsStateNonceFlowAbuse(t *testing.T) {
now := time.Now()
flow := Authorization{State: "expected", Nonce: "nonce", CodeVerifier: "verifier", ExpiresAt: now.Add(time.Minute)}
if err := ValidateCallback(flow, "wrong", "code", now); err == nil {
t.Fatal("wrong state was accepted")
}
if err := ValidateCallback(flow, flow.State, "", now); err == nil {
t.Fatal("empty code was accepted")
}
flow.ExpiresAt = now.Add(-time.Second)
if err := ValidateCallback(flow, flow.State, "code", now); err == nil {
t.Fatal("expired flow was accepted")
}
}
func TestRoleMappingAndAuthorizationMatrix(t *testing.T) {
mapping := map[string]Role{"pulse-view": RoleViewer, "pulse-operator": RoleOperator, "pulse-admin": RoleAdministrator}
role, err := MapRoles([]string{"unrelated", "pulse-operator", "pulse-view"}, mapping)
if err != nil || role != RoleOperator {
t.Fatalf("role mapping = %q, %v", role, err)
}
if _, err := MapRoles([]string{"unrelated"}, mapping); err == nil {
t.Fatal("unmapped claims were authorized")
}
for _, test := range []struct {
role Role
permission Permission
allowed bool
}{
{RoleViewer, PermissionView, true}, {RoleViewer, PermissionEdit, false},
{RoleOperator, PermissionOperate, true}, {RoleOperator, PermissionAdmin, false},
{RoleEditor, PermissionEdit, true}, {RoleEditor, PermissionAdmin, false},
{RoleAdministrator, PermissionAdmin, true},
} {
if got := Allows(test.role, test.permission); got != test.allowed {
t.Errorf("Allows(%s, %s) = %v, want %v", test.role, test.permission, got, test.allowed)
}
}
}
func TestUnauthorizedPathsAreDenied(t *testing.T) {
handler := Require(PermissionEdit, http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.WriteHeader(http.StatusNoContent) }))
for _, test := range []struct {
name string
ctx context.Context
status int
}{
{"anonymous", context.Background(), http.StatusUnauthorized},
{"viewer", WithPrincipal(context.Background(), Principal{Subject: "user-1", Role: RoleViewer}), http.StatusForbidden},
{"editor", WithPrincipal(context.Background(), Principal{Subject: "user-1", Role: RoleEditor}), http.StatusNoContent},
} {
t.Run(test.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/protected", nil).WithContext(test.ctx)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != test.status {
t.Fatalf("status = %d, want %d", response.Code, test.status)
}
if test.status == http.StatusUnauthorized || test.status == http.StatusForbidden {
if response.Header().Get("Cache-Control") != "private, no-store" {
t.Fatalf("cache control = %q", response.Header().Get("Cache-Control"))
}
}
})
}
}
func TestBreakGlassIsDisabledByDefault(t *testing.T) {
if (BreakGlassPolicy{}).Allows() {
t.Fatal("break-glass unexpectedly enabled")
}
}
func TestDiscoverResolvesEndpointAndVerifier(t *testing.T) {
var issuer string
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/.well-known/openid-configuration" {
response.WriteHeader(http.StatusNotFound)
return
}
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write([]byte(`{"issuer":"` + issuer + `","authorization_endpoint":"` + issuer + `/authorize","token_endpoint":"` + issuer + `/token","jwks_uri":"` + issuer + `/jwks","id_token_signing_alg_values_supported":["RS256"]}`))
}))
defer server.Close()
issuer = server.URL
discovery, err := Discover(context.Background(), OIDCConfig{Issuer: issuer, ClientID: "pulse"})
if err != nil {
t.Fatalf("Discover: %v", err)
}
if discovery.Endpoint.AuthURL != issuer+"/authorize" || discovery.Endpoint.TokenURL != issuer+"/token" || discovery.Verifier == nil {
t.Fatalf("discovery = %#v", discovery.Endpoint)
}
if _, err := NewVerifier(context.Background(), OIDCConfig{Issuer: issuer, ClientID: "pulse"}); err != nil {
t.Fatalf("NewVerifier: %v", err)
}
}
func TestDiscoverRejectsIncompleteOrUnreachableIssuer(t *testing.T) {
unreachable := httptest.NewServer(http.NewServeMux())
unreachable.Close()
for _, test := range []struct {
name string
config OIDCConfig
}{
{"missing issuer", OIDCConfig{ClientID: "pulse"}},
{"missing client id", OIDCConfig{Issuer: "https://idp.example"}},
{"unreachable issuer", OIDCConfig{Issuer: unreachable.URL, ClientID: "pulse"}},
} {
t.Run(test.name, func(t *testing.T) {
discovery, err := Discover(context.Background(), test.config)
if err == nil {
t.Fatal("incomplete configuration was accepted")
}
if discovery.Verifier != nil {
t.Fatal("a verifier was returned with an error")
}
if strings.Contains(err.Error(), test.config.Issuer) && test.config.Issuer != "" {
t.Fatalf("error leaks the issuer: %v", err)
}
})
}
}
func TestExtractIdentityRequiresToken(t *testing.T) {
if _, err := ExtractIdentity(nil, "groups"); err == nil {
t.Fatal("nil token was accepted")
}
}
func TestNormalizeGroupClaimBoundsAndShapes(t *testing.T) {
many, err := json.Marshal(make([]string, maxIdentityGroups+50))
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
raw string
want []string
wantErr bool
}{
{name: "list", raw: `["pulse-admin"," pulse-view ",""]`, want: []string{"pulse-admin", "pulse-view"}},
{name: "single string", raw: `"pulse-admin"`, want: []string{"pulse-admin"}},
{name: "empty list", raw: `[]`, want: []string{}},
{name: "object", raw: `{"groups":["pulse-admin"]}`, wantErr: true},
{name: "number", raw: `7`, wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
groups, err := normalizeGroupClaim(json.RawMessage(test.raw))
if (err != nil) != test.wantErr {
t.Fatalf("err = %v, wantErr = %v", err, test.wantErr)
}
if err != nil {
return
}
if len(groups) != len(test.want) {
t.Fatalf("groups = %#v, want %#v", groups, test.want)
}
for index, value := range test.want {
if groups[index] != value {
t.Fatalf("groups = %#v, want %#v", groups, test.want)
}
}
})
}
bounded, err := normalizeGroupClaim(many)
if err != nil {
t.Fatalf("normalizeGroupClaim: %v", err)
}
if len(bounded) != 0 {
t.Fatalf("blank group values were kept: %d", len(bounded))
}
filled := make([]string, maxIdentityGroups+50)
for index := range filled {
filled[index] = "group"
}
encoded, err := json.Marshal(filled)
if err != nil {
t.Fatal(err)
}
capped, err := normalizeGroupClaim(encoded)
if err != nil {
t.Fatalf("normalizeGroupClaim: %v", err)
}
if len(capped) != maxIdentityGroups {
t.Fatalf("groups = %d, want %d", len(capped), maxIdentityGroups)
}
}