feat: implement T09, T15, T21 — userinfo endpoint, LLDAP export, negative tests

- T09: /userinfo with RS256 JWT validation, scope-filtered claims
- T15: LLDAP→canonical export tool with validation, migration_event telemetry
- T21: Negative test suite (Scenario D) — all 7 unsupported features verified

All go tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 02:08:03 +01:00
parent 4097a7de8b
commit 3ee8090a98
9 changed files with 1156 additions and 2 deletions

View File

@@ -0,0 +1,182 @@
// Package negative_test contains integration-style tests that exercise the
// enforcement layer against a real HTTP test server (Scenario D from the
// Acceptance Test Matrix, spec §7).
//
// Each test verifies that:
// 1. The correct error.error string appears in the JSON response.
// 2. The appropriate HTTP status code is returned.
// 3. Content-Type is application/json.
package negative_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
profileerrors "keycape/internal/errors"
serverrors "keycape/internal/server/errors"
)
// ---------------------------------------------------------------------------
// Test infrastructure
// ---------------------------------------------------------------------------
// passthroughHandler is the terminal handler behind the enforcement middleware.
// It returns 200 OK so tests can verify that unmatched requests pass through.
var passthroughHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// newServer builds a test server with DefaultRegistry middleware and the
// pass-through handler.
func newServer(t *testing.T) *httptest.Server {
t.Helper()
reg := serverrors.DefaultRegistry()
return httptest.NewServer(reg.Middleware(passthroughHandler))
}
// get issues a GET request to the given path on the test server.
func get(t *testing.T, srv *httptest.Server, path string) *http.Response {
t.Helper()
resp, err := http.Get(srv.URL + path)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
return resp
}
// post issues a POST request to the given path on the test server.
func post(t *testing.T, srv *httptest.Server, path string) *http.Response {
t.Helper()
resp, err := http.Post(srv.URL+path, "application/x-www-form-urlencoded", nil)
if err != nil {
t.Fatalf("POST %s: %v", path, err)
}
return resp
}
// assertProfileError decodes the JSON body and checks the error field, HTTP status,
// and Content-Type for every negative scenario.
func assertProfileError(t *testing.T, resp *http.Response, wantErrType profileerrors.ErrorType, wantStatus int) {
t.Helper()
defer resp.Body.Close()
if resp.StatusCode != wantStatus {
t.Errorf("HTTP status: want %d, got %d", wantStatus, resp.StatusCode)
}
ct := resp.Header.Get("Content-Type")
if ct == "" {
t.Error("Content-Type must be set")
} else {
// application/json is required; may include charset suffix.
found := false
for _, part := range []string{"application/json"} {
if len(ct) >= len(part) && ct[:len(part)] == part {
found = true
break
}
}
if !found {
t.Errorf("Content-Type: want application/json, got %q", ct)
}
}
var pe profileerrors.ProfileError
if err := json.NewDecoder(resp.Body).Decode(&pe); err != nil {
t.Fatalf("decode ProfileError JSON: %v", err)
}
if pe.Error != wantErrType {
t.Errorf("error field: want %q, got %q", wantErrType, pe.Error)
}
}
// ---------------------------------------------------------------------------
// Scenario D — Negative Profile Tests (one per unsupported feature)
// ---------------------------------------------------------------------------
// 1. dynamic_client_registration — POST /connect/register → feature_not_supported_by_profile
func TestNegative_DynamicClientRegistration(t *testing.T) {
srv := newServer(t)
defer srv.Close()
resp := post(t, srv, "/connect/register")
assertProfileError(t, resp, profileerrors.ErrFeatureNotSupported, http.StatusNotImplemented)
}
// 2. implicit_flow — GET /authorize?response_type=token → rejected_for_profile_safety
func TestNegative_ImplicitFlow(t *testing.T) {
srv := newServer(t)
defer srv.Close()
resp := get(t, srv, "/authorize?response_type=token")
assertProfileError(t, resp, profileerrors.ErrRejectedForSafety, http.StatusForbidden)
}
// 3. wildcard_redirect_uri — GET /authorize?redirect_uri=https://evil.com/* → rejected_for_profile_safety
func TestNegative_WildcardRedirectURI(t *testing.T) {
srv := newServer(t)
defer srv.Close()
resp := get(t, srv, "/authorize?redirect_uri=https%3A%2F%2Fevil.com%2F*")
assertProfileError(t, resp, profileerrors.ErrRejectedForSafety, http.StatusForbidden)
}
// 4. identity_broker — GET /broker/google → available_in_keycloak_mode_only
func TestNegative_IdentityBroker(t *testing.T) {
srv := newServer(t)
defer srv.Close()
resp := get(t, srv, "/broker/google")
assertProfileError(t, resp, profileerrors.ErrKeycloakModeOnly, http.StatusNotImplemented)
}
// 5. missing_pkce — GET /authorize (without code_challenge) → invalid_profile_usage
func TestNegative_MissingPKCE(t *testing.T) {
srv := newServer(t)
defer srv.Close()
// No code_challenge parameter → missing_pkce triggers.
resp := get(t, srv, "/authorize?response_type=code&client_id=myapp")
assertProfileError(t, resp, profileerrors.ErrInvalidProfileUsage, http.StatusBadRequest)
}
// 6. pkce_plain_method — GET /authorize?code_challenge=abc&code_challenge_method=plain → rejected_for_profile_safety
func TestNegative_PKCEPlainMethod(t *testing.T) {
srv := newServer(t)
defer srv.Close()
resp := get(t, srv, "/authorize?code_challenge=abc&code_challenge_method=plain")
assertProfileError(t, resp, profileerrors.ErrRejectedForSafety, http.StatusForbidden)
}
// 7. unknown_grant_type — POST /token?grant_type=password → feature_not_supported_by_profile
func TestNegative_UnknownGrantType(t *testing.T) {
srv := newServer(t)
defer srv.Close()
resp := post(t, srv, "/token?grant_type=password")
assertProfileError(t, resp, profileerrors.ErrFeatureNotSupported, http.StatusNotImplemented)
}
// ---------------------------------------------------------------------------
// Positive scenario: a normal valid request must pass through enforcement.
// ---------------------------------------------------------------------------
// TestNegative_ValidRequest_PassesThrough verifies that a well-formed authorization
// code request (with code_challenge and S256 method) reaches the terminal handler.
func TestNegative_ValidRequest_PassesThrough(t *testing.T) {
srv := newServer(t)
defer srv.Close()
resp, err := http.Get(srv.URL + "/authorize?response_type=code&code_challenge=abc&code_challenge_method=S256&client_id=myapp")
if err != nil {
t.Fatalf("GET /authorize: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200 (pass-through), got %d", resp.StatusCode)
}
}