generated from coulomb/repo-seed
feat: implement T01-T04 — Go module, canonical model, LDAP validator, error taxonomy
- T01: Go module (keycape), full directory skeleton, Makefile, CI workflow - T02: spec/canonical-model.yaml with 6 entities + Go domain types - T03: spec/ldap-schema.yaml + validator binary with structural/semantic rules - T04: Error taxonomy — 4 stable error types, JSON format, HTTP helpers 28 tests pass, go vet clean, go build clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
85
src/internal/errors/taxonomy.go
Normal file
85
src/internal/errors/taxonomy.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Package errors implements the KeyCape error taxonomy from spec §5.
|
||||
// All profile errors are structured and machine-readable.
|
||||
// Errors MUST NOT be silent — every unsupported or misused feature returns a typed error.
|
||||
package errors
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ErrorType is a stable string identifier for profile error categories.
|
||||
type ErrorType string
|
||||
|
||||
const (
|
||||
// ErrFeatureNotSupported is returned when a feature is outside the NetKingdom IAM Profile.
|
||||
ErrFeatureNotSupported ErrorType = "feature_not_supported_by_profile"
|
||||
|
||||
// ErrKeycloakModeOnly is returned when a feature exists only in expanded (Keycloak) mode.
|
||||
ErrKeycloakModeOnly ErrorType = "available_in_keycloak_mode_only"
|
||||
|
||||
// ErrRejectedForSafety is returned when a feature is intentionally blocked for security reasons.
|
||||
ErrRejectedForSafety ErrorType = "rejected_for_profile_safety"
|
||||
|
||||
// ErrInvalidProfileUsage is returned when a supported endpoint/feature is used incorrectly.
|
||||
ErrInvalidProfileUsage ErrorType = "invalid_profile_usage"
|
||||
)
|
||||
|
||||
// ProfileError is a structured error per spec §5.2.
|
||||
// JSON format: {"error": "...", "description": "...", "feature": "..."}
|
||||
type ProfileError struct {
|
||||
Error ErrorType `json:"error"`
|
||||
Description string `json:"description"`
|
||||
Feature string `json:"feature,omitempty"`
|
||||
}
|
||||
|
||||
// Write writes the error as JSON with the given HTTP status code.
|
||||
func (e *ProfileError) Write(w http.ResponseWriter, status int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(e)
|
||||
}
|
||||
|
||||
// GoError implements the standard error interface.
|
||||
func (e *ProfileError) GoError() string {
|
||||
if e.Feature != "" {
|
||||
return string(e.Error) + ": " + e.Description + " [feature=" + e.Feature + "]"
|
||||
}
|
||||
return string(e.Error) + ": " + e.Description
|
||||
}
|
||||
|
||||
// FeatureNotSupported constructs a feature_not_supported_by_profile error.
|
||||
func FeatureNotSupported(description, feature string) *ProfileError {
|
||||
return &ProfileError{
|
||||
Error: ErrFeatureNotSupported,
|
||||
Description: description,
|
||||
Feature: feature,
|
||||
}
|
||||
}
|
||||
|
||||
// KeycloakModeOnly constructs an available_in_keycloak_mode_only error.
|
||||
func KeycloakModeOnly(description, feature string) *ProfileError {
|
||||
return &ProfileError{
|
||||
Error: ErrKeycloakModeOnly,
|
||||
Description: description,
|
||||
Feature: feature,
|
||||
}
|
||||
}
|
||||
|
||||
// RejectedForSafety constructs a rejected_for_profile_safety error.
|
||||
func RejectedForSafety(description, feature string) *ProfileError {
|
||||
return &ProfileError{
|
||||
Error: ErrRejectedForSafety,
|
||||
Description: description,
|
||||
Feature: feature,
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidProfileUsage constructs an invalid_profile_usage error.
|
||||
func InvalidProfileUsage(description, feature string) *ProfileError {
|
||||
return &ProfileError{
|
||||
Error: ErrInvalidProfileUsage,
|
||||
Description: description,
|
||||
Feature: feature,
|
||||
}
|
||||
}
|
||||
141
src/internal/errors/taxonomy_test.go
Normal file
141
src/internal/errors/taxonomy_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package errors_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
profileerrors "keycape/internal/errors"
|
||||
)
|
||||
|
||||
func TestErrorTypeConstants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errType profileerrors.ErrorType
|
||||
expected string
|
||||
}{
|
||||
{"FeatureNotSupported", profileerrors.ErrFeatureNotSupported, "feature_not_supported_by_profile"},
|
||||
{"KeycloakModeOnly", profileerrors.ErrKeycloakModeOnly, "available_in_keycloak_mode_only"},
|
||||
{"RejectedForSafety", profileerrors.ErrRejectedForSafety, "rejected_for_profile_safety"},
|
||||
{"InvalidProfileUsage", profileerrors.ErrInvalidProfileUsage, "invalid_profile_usage"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if string(tt.errType) != tt.expected {
|
||||
t.Errorf("got %q, want %q", tt.errType, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstructorHelpers(t *testing.T) {
|
||||
t.Run("FeatureNotSupported", func(t *testing.T) {
|
||||
e := profileerrors.FeatureNotSupported("dynamic registration is not allowed", "dynamic_client_registration")
|
||||
if e.Error != profileerrors.ErrFeatureNotSupported {
|
||||
t.Errorf("wrong error type: %v", e.Error)
|
||||
}
|
||||
if e.Description != "dynamic registration is not allowed" {
|
||||
t.Errorf("wrong description: %v", e.Description)
|
||||
}
|
||||
if e.Feature != "dynamic_client_registration" {
|
||||
t.Errorf("wrong feature: %v", e.Feature)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("KeycloakModeOnly", func(t *testing.T) {
|
||||
e := profileerrors.KeycloakModeOnly("identity broker requires expanded mode", "identity_broker")
|
||||
if e.Error != profileerrors.ErrKeycloakModeOnly {
|
||||
t.Errorf("wrong error type: %v", e.Error)
|
||||
}
|
||||
if e.Feature != "identity_broker" {
|
||||
t.Errorf("wrong feature: %v", e.Feature)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RejectedForSafety", func(t *testing.T) {
|
||||
e := profileerrors.RejectedForSafety("wildcard redirect URIs weaken security", "wildcard_redirect_uri")
|
||||
if e.Error != profileerrors.ErrRejectedForSafety {
|
||||
t.Errorf("wrong error type: %v", e.Error)
|
||||
}
|
||||
if e.Feature != "wildcard_redirect_uri" {
|
||||
t.Errorf("wrong feature: %v", e.Feature)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InvalidProfileUsage", func(t *testing.T) {
|
||||
e := profileerrors.InvalidProfileUsage("PKCE code_challenge is required", "missing_pkce")
|
||||
if e.Error != profileerrors.ErrInvalidProfileUsage {
|
||||
t.Errorf("wrong error type: %v", e.Error)
|
||||
}
|
||||
if e.Feature != "missing_pkce" {
|
||||
t.Errorf("wrong feature: %v", e.Feature)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProfileErrorJSON(t *testing.T) {
|
||||
e := profileerrors.FeatureNotSupported("dynamic registration is not allowed", "dynamic_client_registration")
|
||||
data, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal error: %v", err)
|
||||
}
|
||||
s := string(data)
|
||||
if !strings.Contains(s, `"error":"feature_not_supported_by_profile"`) {
|
||||
t.Errorf("missing error field: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, `"description":"dynamic registration is not allowed"`) {
|
||||
t.Errorf("missing description field: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, `"feature":"dynamic_client_registration"`) {
|
||||
t.Errorf("missing feature field: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileErrorOmitsFeatureWhenEmpty(t *testing.T) {
|
||||
e := &profileerrors.ProfileError{
|
||||
Error: profileerrors.ErrInvalidProfileUsage,
|
||||
Description: "bad request",
|
||||
}
|
||||
data, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal error: %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), `"feature"`) {
|
||||
t.Errorf("feature field should be omitted when empty: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileErrorWrite(t *testing.T) {
|
||||
e := profileerrors.FeatureNotSupported("not supported", "some_feature")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
e.Write(rr, http.StatusBadRequest)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d", rr.Code)
|
||||
}
|
||||
ct := rr.Header().Get("Content-Type")
|
||||
if ct != "application/json" {
|
||||
t.Errorf("expected Content-Type application/json, got %q", ct)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "feature_not_supported_by_profile") {
|
||||
t.Errorf("body missing error type: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileErrorGoError(t *testing.T) {
|
||||
e := profileerrors.FeatureNotSupported("desc", "feat")
|
||||
s := e.GoError()
|
||||
if !strings.Contains(s, "feature_not_supported_by_profile") {
|
||||
t.Errorf("GoError missing error type: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, "desc") {
|
||||
t.Errorf("GoError missing description: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, "feat") {
|
||||
t.Errorf("GoError missing feature: %s", s)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user