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) } }