generated from coulomb/repo-seed
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:
182
src/tests/negative/negative_test.go
Normal file
182
src/tests/negative/negative_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user