package relationship_test import ( "context" "errors" "path/filepath" "testing" "github.com/netkingdom/flex-auth/internal/adapters/relationship" "github.com/netkingdom/flex-auth/internal/registry" "github.com/netkingdom/flex-auth/pkg/api" ) func TestSnapshotToTuplesMapsCanonicalRegistry(t *testing.T) { tuples := relationship.SnapshotToTuples(loadRegistry(t).Snapshot()) want := relationship.Tuple{ ObjectType: "document", ObjectID: "document:internal-note", Relation: "reader", SubjectType: "group", SubjectID: "group:platform-architecture", SubjectRelation: "member", } got := findTuple(tuples, want) if got.Caring == nil || got.Caring.CanonicalRole != api.CanonicalRoleDoer { t.Fatalf("reader tuple CARING = %+v; want Doer descriptor", got.Caring) } if len(got.Conditions) != 1 || got.Conditions[0] != api.ConditionLogged { t.Fatalf("conditions = %+v; want Logged", got.Conditions) } } func TestAdapterCheckPreservesCARINGFromMatchedTuple(t *testing.T) { descriptor := caringDescriptor() backend := &fakeBackend{ checkResult: relationship.TupleCheckResult{ Allowed: true, Reason: "direct_tuple", ConsistencyToken: "zed:42", MatchedTuples: []relationship.Tuple{ { ObjectType: "document", ObjectID: "document:internal-note", Relation: "reader", SubjectType: "group", SubjectID: "group:platform-architecture", Caring: descriptor, }, }, InheritedFrom: []relationship.Tuple{ {ObjectType: "knowledge_base", ObjectID: "knowledge-base:markitect-example", Relation: "reader"}, }, Diagnostics: map[string]any{"backend_trace": "trace-1"}, }, } adapter := newAdapter(t, backend) got, err := adapter.Check(context.Background(), api.CheckRequest{ ID: "check:relationship-allow", Subject: api.SubjectRef{ID: "user:alice", Type: api.SubjectTypeHuman}, Action: "read", Resource: api.ResourceRef{ ID: "document:internal-note", Type: "document", System: "markitect-tool", }, }) if err != nil { t.Fatalf("Check: %v", err) } if got.Effect != api.DecisionEffectAllow || got.Reason != "direct_tuple" { t.Fatalf("decision = %s/%s; want allow/direct_tuple", got.Effect, got.Reason) } if got.Provenance.Evaluator != "relationship-pdp/openfga" || got.Provenance.DirectoryETag != "zed:42" { t.Fatalf("provenance = %+v", got.Provenance) } if got.Diagnostics["matched_tuples"] != 1 || got.Diagnostics["inherited_tuples"] != 1 { t.Fatalf("diagnostics = %+v; want matched and inherited counts", got.Diagnostics) } if got.Caring == nil || got.Caring.Descriptor == nil { t.Fatal("missing CARING metadata") } if len(got.Caring.DerivedCapabilities) != 1 || got.Caring.DerivedCapabilities[0].Capability != api.CapabilityViewCollection { t.Fatalf("derived capabilities = %+v", got.Caring.DerivedCapabilities) } if got.Caring.ExposureEvent == nil || got.Caring.ExposureEvent.Type != api.ExposureEventSupport { t.Fatalf("exposure event = %+v", got.Caring.ExposureEvent) } } func TestAdapterCheckFailsClosedOnStaleBackend(t *testing.T) { backend := &fakeBackend{ checkErr: relationship.NewBackendError(relationship.FailureStaleData, "check", errors.New("zed token too old")), } adapter := newAdapter(t, backend) got, err := adapter.Check(context.Background(), api.CheckRequest{ ID: "check:stale", Subject: api.SubjectRef{ID: "user:alice"}, Action: "read", Resource: api.ResourceRef{ID: "document:internal-note", Type: "document", System: "markitect-tool"}, CaringContext: caringDescriptor(), }) if err != nil { t.Fatalf("Check: %v", err) } if got.Effect != api.DecisionEffectDeny || got.Reason != "relationship_data_stale" { t.Fatalf("decision = %s/%s; want stale deny", got.Effect, got.Reason) } if got.Diagnostics["relationship_failure"] != "stale_data" { t.Fatalf("diagnostics = %+v", got.Diagnostics) } if got.Caring.ConformanceFindings[0].Code != "RELATIONSHIP-DATA-STALE" { t.Fatalf("finding = %+v", got.Caring.ConformanceFindings[0]) } } func TestBatchCheckPreservesBackendOrder(t *testing.T) { backend := &fakeBackend{ batchResults: []relationship.TupleCheckResult{ {Allowed: true, ConsistencyToken: "zed:1"}, {Allowed: false, Reason: "no_tuple", ConsistencyToken: "zed:2"}, }, } adapter := newAdapter(t, backend) got, err := adapter.BatchCheck(context.Background(), api.BatchCheckRequest{ ID: "batch:documents", Subject: api.SubjectRef{ID: "user:alice"}, Action: "read", Resources: []api.ResourceRef{ {ID: "document:internal-note", Type: "document", System: "markitect-tool"}, {ID: "document:missing", Type: "document", System: "markitect-tool"}, }, }) if err != nil { t.Fatalf("BatchCheck: %v", err) } if len(got) != 2 { t.Fatalf("len(got) = %d; want 2", len(got)) } if got[0].Effect != api.DecisionEffectAllow || got[1].Effect != api.DecisionEffectDeny { t.Fatalf("effects = %s/%s; want allow/deny", got[0].Effect, got[1].Effect) } if got[0].Resource.ID != "document:internal-note" || got[1].Resource.ID != "document:missing" { t.Fatalf("resource order = %s/%s", got[0].Resource.ID, got[1].Resource.ID) } } func TestListAllowedReturnsEnvelopeResults(t *testing.T) { backend := &fakeBackend{ listResult: relationship.TupleListResult{ ConsistencyToken: "zed:list", Objects: []relationship.ListedObject{ { Resource: api.ResourceRef{ ID: "document:internal-note", Type: "document", System: "markitect-tool", }, CaringDescriptor: caringDescriptor(), }, }, }, } adapter := newAdapter(t, backend) got, err := adapter.ListAllowed(context.Background(), relationship.TupleListRequest{ Subject: api.SubjectRef{ID: "user:alice"}, Relation: "read", ResourceType: "document", System: "markitect-tool", }) if err != nil { t.Fatalf("ListAllowed: %v", err) } if got.ConsistencyToken != "zed:list" || len(got.Decisions) != 1 { t.Fatalf("list result = %+v", got) } if got.Decisions[0].Effect != api.DecisionEffectAllow || got.Decisions[0].Reason != "relationship_list_allowed" { t.Fatalf("decision = %+v", got.Decisions[0]) } } func TestImportTuplesDelegatesSnapshotTuples(t *testing.T) { backend := &fakeBackend{} adapter := newAdapter(t, backend) report, err := adapter.ImportTuples(context.Background(), loadRegistry(t).Snapshot()) if err != nil { t.Fatalf("ImportTuples: %v", err) } if report.TuplesWritten != len(backend.imported) { t.Fatalf("report = %+v imported=%d", report, len(backend.imported)) } if findTuple(backend.imported, relationship.Tuple{ ObjectType: "document", ObjectID: "document:internal-note", Relation: "reader", SubjectType: "group", SubjectID: "group:platform-architecture", SubjectRelation: "member", }).Caring == nil { t.Fatal("imported reader tuple lost CARING descriptor") } } func newAdapter(t *testing.T, backend *fakeBackend) *relationship.Adapter { t.Helper() adapter, err := relationship.New(backend, relationship.Options{ BackendName: "openfga", PolicyPackage: "relationship.boundary", PolicyVersion: "v1", }) if err != nil { t.Fatalf("New: %v", err) } return adapter } func loadRegistry(t *testing.T) *registry.Store { t.Helper() store, err := registry.LoadFile(filepath.Join("..", "..", "..", "examples", "caring", "registry_snapshot.json")) if err != nil { t.Fatalf("LoadFile: %v", err) } return store } func caringDescriptor() *api.CaringAccessDescriptor { return &api.CaringAccessDescriptor{ ID: "descriptor:tuple-reader", Profile: api.CaringProfileCaring040RC2, SubjectType: api.SubjectTypeGroup, OrganizationRelation: api.OrganizationRelationCustomer, CanonicalRole: api.CanonicalRoleDoer, Scope: api.CaringScope{ Level: api.ScopeLevelResource, ID: "document:internal-note", Tenant: "tenant:alpha", Resource: "document:internal-note", }, Planes: []api.Plane{api.PlaneData}, Capabilities: []api.Capability{api.CapabilityView}, ExposureModes: []api.ExposureMode{api.ExposureModeMasked}, Restrictions: []api.Restriction{api.RestrictionExportBlocked}, ExposureEvent: api.ExposureEventSupport, DerivedCapabilities: []api.CaringDerivedCapability{ {Capability: api.CapabilityViewCollection, Reason: "parent reader inheritance", Source: "tuple:parent"}, }, } } func findTuple(tuples []relationship.Tuple, want relationship.Tuple) relationship.Tuple { for _, tuple := range tuples { if tuple.ObjectType == want.ObjectType && tuple.ObjectID == want.ObjectID && tuple.Relation == want.Relation && tuple.SubjectType == want.SubjectType && tuple.SubjectID == want.SubjectID && tuple.SubjectRelation == want.SubjectRelation { return tuple } } panic("tuple not found") } type fakeBackend struct { checkResult relationship.TupleCheckResult checkErr error batchResults []relationship.TupleCheckResult listResult relationship.TupleListResult imported []relationship.Tuple } func (b *fakeBackend) Check(context.Context, relationship.TupleCheckRequest) (relationship.TupleCheckResult, error) { return b.checkResult, b.checkErr } func (b *fakeBackend) BatchCheck(_ context.Context, requests []relationship.TupleCheckRequest) ([]relationship.TupleCheckResult, error) { if b.batchResults != nil { return b.batchResults, nil } results := make([]relationship.TupleCheckResult, len(requests)) for i := range results { results[i] = b.checkResult } return results, b.checkErr } func (b *fakeBackend) ListObjects(context.Context, relationship.TupleListRequest) (relationship.TupleListResult, error) { return b.listResult, nil } func (b *fakeBackend) ImportTuples(_ context.Context, tuples []relationship.Tuple) (relationship.ImportReport, error) { b.imported = append([]relationship.Tuple(nil), tuples...) return relationship.ImportReport{TuplesWritten: len(tuples), ConsistencyToken: "zed:import"}, nil } func (b *fakeBackend) Health(context.Context) error { return nil }