flex-auth/pkg/api/canonical_test.go
tegwick 56940727bf
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Successful in 57s
Finish FLEX-WP-0019 layer-model v0.7 conformance
Close the remaining PDP obligations: mechanical layer declaration check,
registry-snapshot digest in provenance, explicit allow TTL, per-input-class
freshness deadlines, and the published decision-record contract. Document
the canonical request digest as the §6.4.2 replay test.

Assistant: grok
Assistant-Session: 01a06256-fb71-7102-b3a9-27e6734257d0
2026-09-03 23:48:45 +02:00

240 lines
8.2 KiB
Go

package api_test
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"gopkg.in/yaml.v3"
"github.com/netkingdom/flex-auth/pkg/api"
)
func TestCaringAccessDescriptorExampleParses(t *testing.T) {
var got api.CaringAccessDescriptor
loadYAML(t, filepath.Join("..", "..", "examples", "caring", "access_descriptor.yaml"), &got)
if got.Profile != api.CaringProfileCaring040RC2 {
t.Fatalf("Profile = %q; want %q", got.Profile, api.CaringProfileCaring040RC2)
}
if got.CanonicalRole != api.CanonicalRoleDoer {
t.Errorf("CanonicalRole = %q; want Doer", got.CanonicalRole)
}
if got.Scope.Level != api.ScopeLevelResource || got.Scope.Tenant != "tenant:alpha" {
t.Errorf("Scope = %+v; want resource scope in tenant:alpha", got.Scope)
}
if len(got.Restrictions) != 1 || got.Restrictions[0] != api.RestrictionExportBlocked {
t.Errorf("Restrictions = %v; want [ExportBlocked]", got.Restrictions)
}
}
func TestCheckRequestExampleParses(t *testing.T) {
var got api.CheckRequest
loadYAML(t, filepath.Join("..", "..", "examples", "caring", "check_request.yaml"), &got)
if got.Subject.ID != "user:alice" {
t.Errorf("Subject.ID = %q; want user:alice", got.Subject.ID)
}
if got.Resource.ID != "document:internal-note" {
t.Errorf("Resource.ID = %q; want document:internal-note", got.Resource.ID)
}
if got.CaringContext == nil {
t.Fatal("CaringContext is nil")
}
if got.CaringContext.AccessPath != api.AccessPathDirect {
t.Errorf("CaringContext.AccessPath = %q; want direct", got.CaringContext.AccessPath)
}
}
func TestRegistryExamplesParse(t *testing.T) {
var subjects api.SubjectManifest
loadYAML(t, filepath.Join("..", "..", "examples", "caring", "subject_manifest.yaml"), &subjects)
if len(subjects.Subjects) != 1 || subjects.Subjects[0].Type != api.SubjectTypeHuman {
t.Fatalf("subjects did not parse as expected: %+v", subjects.Subjects)
}
var relationship api.RelationshipFact
loadYAML(t, filepath.Join("..", "..", "examples", "caring", "relationship_fact.yaml"), &relationship)
if relationship.Caring == nil {
t.Fatal("RelationshipFact.Caring is nil")
}
if relationship.Caring.SubjectType != api.SubjectTypeGroup {
t.Errorf("RelationshipFact.Caring.SubjectType = %q; want Group", relationship.Caring.SubjectType)
}
}
func TestDecisionAndAuditExamplesParse(t *testing.T) {
var decision api.DecisionEnvelope
loadJSON(t, filepath.Join("..", "..", "examples", "caring", "decision_envelope.json"), &decision)
if decision.Effect != api.DecisionEffectAllow {
t.Errorf("Decision.Effect = %q; want allow", decision.Effect)
}
if decision.Lifetime == nil || decision.Lifetime.Kind != api.DecisionLifetimeTTL {
t.Fatalf("Decision.Lifetime = %+v; want ttl", decision.Lifetime)
}
if decision.ContractVersion != api.DecisionRecordContractV1 {
t.Errorf("Decision.ContractVersion = %q", decision.ContractVersion)
}
if decision.Caring == nil || decision.Caring.Profile != api.CaringProfileCaring040RC2 {
t.Fatalf("Decision.Caring = %+v; want CARING profile metadata", decision.Caring)
}
if len(decision.Caring.ConformanceFindings) != 1 {
t.Errorf("ConformanceFindings len = %d; want 1", len(decision.Caring.ConformanceFindings))
}
var audit api.AuditEvent
loadJSON(t, filepath.Join("..", "..", "examples", "caring", "audit_event.json"), &audit)
if audit.DecisionID != decision.ID {
t.Errorf("Audit.DecisionID = %q; want %q", audit.DecisionID, decision.ID)
}
var exposure api.CaringExposureEvent
loadJSON(t, filepath.Join("..", "..", "examples", "caring", "exposure_event.json"), &exposure)
if exposure.Type != api.ExposureEventSupport {
t.Errorf("Exposure.Type = %q; want X-Support", exposure.Type)
}
if len(exposure.ExposureModes) != 1 || exposure.ExposureModes[0] != api.ExposureModeMasked {
t.Errorf("Exposure.ExposureModes = %v; want [Masked]", exposure.ExposureModes)
}
}
func TestActionAuthorizationExampleParses(t *testing.T) {
var authorization api.ActionAuthorization
loadJSON(t, filepath.Join("..", "..", "examples", "caring", "action_authorization.json"), &authorization)
if authorization.Status != api.ActionAuthorizationApproved {
t.Fatalf("Status = %q; want approved", authorization.Status)
}
if authorization.Request.Action != "destroy" || authorization.Decision.Binding == nil {
t.Fatalf("authorization is not action-bound: %+v", authorization)
}
if authorization.Approvals.RequiredCount != 2 || len(authorization.Approvals.Entries) != 2 {
t.Fatalf("Approvals = %+v; want two-person approval", authorization.Approvals)
}
wantBinding := api.NewDecisionBinding(authorization.Request)
if !reflect.DeepEqual(authorization.Decision.Binding, wantBinding) {
t.Fatalf("Decision.Binding = %+v; want canonical binding %+v", authorization.Decision.Binding, wantBinding)
}
}
func TestRequestDigestIgnoresIDAndChangesWithAction(t *testing.T) {
request := api.CheckRequest{
ID: "check:one",
Tenant: "tenant:alpha",
Subject: api.SubjectRef{ID: "user:alice", Type: api.SubjectTypeHuman},
Action: "read",
Resource: api.ResourceRef{
ID: "document:internal-note",
Type: "document",
System: "markitect-tool",
},
Context: map[string]any{"purpose": "project-delivery"},
}
first := api.RequestDigest(request)
if !strings.HasPrefix(first, "sha256:") || len(first) != len("sha256:")+64 {
t.Fatalf("RequestDigest = %q", first)
}
same := request
same.ID = "check:other"
same.PolicyVersion = "v9"
if api.RequestDigest(same) != first {
t.Fatal("digest changed when only id/policy_version changed")
}
changed := request
changed.Action = "destroy"
if api.RequestDigest(changed) == first {
t.Fatal("digest did not change when action changed")
}
binding := api.NewDecisionBinding(request)
if binding.RequestDigest != first {
t.Fatalf("binding digest %q != RequestDigest %q", binding.RequestDigest, first)
}
}
func TestApplyAllowLifetimeDefaultDeclaredAndNone(t *testing.T) {
now := mustParseTime(t, "2026-08-29T12:00:00Z")
allow := api.DecisionEnvelope{Effect: api.DecisionEffectAllow, Reason: "reader_relation"}
api.ApplyAllowLifetime(&allow, "", now)
if allow.Effect != api.DecisionEffectAllow || allow.Lifetime == nil {
t.Fatalf("default TTL denied or skipped: %+v", allow)
}
if allow.Lifetime.TTL != "15m" || allow.Lifetime.ExpiresAt != "2026-08-29T12:15:00Z" {
t.Fatalf("default lifetime = %+v", allow.Lifetime)
}
declared := api.DecisionEnvelope{Effect: api.DecisionEffectAllow, Reason: "reader_relation"}
api.ApplyAllowLifetime(&declared, "5m", now)
if declared.Lifetime == nil || declared.Lifetime.TTL != "5m" || declared.Lifetime.ExpiresAt != "2026-08-29T12:05:00Z" {
t.Fatalf("declared lifetime = %+v", declared.Lifetime)
}
unstated := api.DecisionEnvelope{Effect: api.DecisionEffectAllow, Reason: "reader_relation"}
api.ApplyAllowLifetime(&unstated, "none", now)
if unstated.Effect != api.DecisionEffectDeny || unstated.Reason != api.ReasonAllowLifetimeUnstated {
t.Fatalf("unstated allow = %+v; want deny", unstated)
}
if unstated.Lifetime != nil {
t.Fatalf("unstated allow still has lifetime %+v", unstated.Lifetime)
}
}
func mustParseTime(t *testing.T, value string) time.Time {
t.Helper()
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
t.Fatalf("parse time %q: %v", value, err)
}
return parsed
}
func TestSchemaFilesAreJSON(t *testing.T) {
schemaDir := filepath.Join("..", "..", "schemas")
entries, err := os.ReadDir(schemaDir)
if err != nil {
t.Fatalf("read schema dir: %v", err)
}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
t.Run(entry.Name(), func(t *testing.T) {
var got map[string]any
loadJSON(t, filepath.Join(schemaDir, entry.Name()), &got)
if got["$schema"] == "" || got["$id"] == "" {
t.Fatalf("%s missing $schema or $id", entry.Name())
}
})
}
}
func loadYAML(t *testing.T, path string, out any) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
if err := yaml.Unmarshal(data, out); err != nil {
t.Fatalf("unmarshal %s: %v", path, err)
}
}
func loadJSON(t *testing.T, path string, out any) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
if err := json.Unmarshal(data, out); err != nil {
t.Fatalf("unmarshal %s: %v", path, err)
}
}