snapshot of https://github.com/ory/keto.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
CI / Run lints and checks (push) Failing after 47s
CI / Run tests (push) Failing after 1s
CI / Run race tests (push) Failing after 5s
CI / Build CLI docs (push) Has been skipped
CI / Run buf actions (push) Failing after 5s
CodeQL / Analyze (go) (push) Failing after 3s
CodeQL / Analyze (javascript) (push) Failing after 2s
Docker Image Scanners / scanners (push) Failing after 3s
Format / format (push) Failing after 3s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Draft newsletter (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 01:08:34 +05:00
commit 9456a4e922
2560 changed files with 120911 additions and 0 deletions

397
internal/expand/engine.go Normal file
View File

@@ -0,0 +1,397 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package expand
import (
"context"
"slices"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
keysetpagination "github.com/ory/x/pagination/keysetpagination_v2"
"github.com/ory/keto/internal/driver/config"
"github.com/ory/keto/internal/namespace"
"github.com/ory/keto/internal/namespace/ast"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/ketoapi"
"github.com/ory/keto/x/events"
)
type (
EngineDependencies interface {
relationtuple.ManagerProvider
config.Provider
logrusx.Provider
otelx.Provider
}
Engine struct {
d EngineDependencies
}
EngineProvider interface {
ExpandEngine() *Engine
}
)
func NewEngine(d EngineDependencies) *Engine {
return &Engine{
d: d,
}
}
// treeBuilder holds state shared across a single BuildTree call.
type treeBuilder struct {
d EngineDependencies
namespaceManager namespace.Manager
nodeCount int
maxNodes int
visited map[relationtuple.SubjectSet]struct{}
isStrict bool
expandRewrites bool
}
func (e *Engine) newTreeBuilder(ctx context.Context, namespaceManager namespace.Manager) *treeBuilder {
return &treeBuilder{
d: e.d,
namespaceManager: namespaceManager,
visited: make(map[relationtuple.SubjectSet]struct{}),
maxNodes: e.d.Config(ctx).ExpandMaxTupleCount(),
nodeCount: 0,
isStrict: e.d.Config(ctx).StrictMode(),
expandRewrites: e.d.Config(ctx).ExpandRewrites(),
}
}
func (e *Engine) BuildTree(ctx context.Context, subSet *relationtuple.SubjectSet, restDepth int) (t *relationtuple.Tree, err error) {
ctx, span := e.d.Tracer(ctx).Tracer().Start(ctx, "Engine.BuildTree")
defer otelx.End(span, &err)
namespaceManager, err := e.d.Config(ctx).NamespaceManager()
if err != nil {
return nil, err
}
// global max-depth takes precedence when it is the lesser or if the request max-depth is less than or equal to 0
if globalMaxDepth := e.d.Config(ctx).MaxReadDepth(); restDepth <= 0 || globalMaxDepth < restDepth {
restDepth = globalMaxDepth
}
b := e.newTreeBuilder(ctx, namespaceManager)
if b.isStrict {
ns, _ := b.namespaceManager.GetNamespaceByName(ctx, subSet.Namespace)
rel := ns.FindRelation(subSet.Relation)
if rel == nil || (len(rel.Types) == 0 && rel.SubjectSetRewrite == nil) {
return nil, nil
}
}
t, err = b.buildTreeRecursive(ctx, subSet, restDepth)
if err == nil {
span.AddEvent(events.NewPermissionsExpanded(ctx))
}
return
}
func (b *treeBuilder) buildTreeRecursive(ctx context.Context, subject relationtuple.Subject, restDepth int) (*relationtuple.Tree, error) {
subSet, isSubjectSet := subject.(*relationtuple.SubjectSet)
if !isSubjectSet || subSet.Relation == "" {
b.nodeCount++
return newNode(ketoapi.TreeNodeLeaf, subject), nil
}
// Cycle detection: if this subject is already being expanded in the current
// call path (DFS ancestry), we have a cycle.
if _, exists := b.visited[*subSet]; exists {
return &relationtuple.Tree{
Type: ketoapi.TreeNodeUnion,
Subject: subject,
Truncation: &relationtuple.Truncation{
Reason: relationtuple.TruncationReasonCycle,
},
}, nil
}
b.visited[*subSet] = struct{}{}
defer delete(b.visited, *subSet)
ns, _ := b.namespaceManager.GetNamespaceByName(ctx, subSet.Namespace)
rel := ns.FindRelation(subSet.Relation)
if rel == nil {
if b.isStrict {
return nil, nil
}
// unknown relation, but we're not in strict mode, so we'll try to build it directly
return b.buildTreeDirect(ctx, subSet, nil, nil, restDepth)
}
if rel.SubjectSetRewrite != nil {
if b.expandRewrites {
return b.buildTreeForRewrite(ctx, subSet, rel.SubjectSetRewrite, restDepth)
}
// it is a rewrite, but we don't expand rewrites
// checking direct doesn't make much sense, but that's what we currently do so we keep that behavior
return b.buildTreeDirect(ctx, subSet, rel, nil, restDepth)
}
// rel is not a "permits" but "related", so we check directly
return b.buildTreeDirect(ctx, subSet, rel, nil, restDepth)
}
// buildTreeDirect expands by querying the relation directly, without an OPL rewrites.
func (b *treeBuilder) buildTreeDirect(ctx context.Context, subSet *relationtuple.SubjectSet, rel *ast.Relation, resume *relationtuple.ExpandCursor, restDepth int) (*relationtuple.Tree, error) {
tree := newNode(ketoapi.TreeNodeUnion, subSet)
directResume := func(pageToken keysetpagination.PageToken) *relationtuple.ExpandCursor {
return &relationtuple.ExpandCursor{Kind: relationtuple.ExpandCursorKindDirect, SubjectSet: subSet, NextPageToken: pageToken}
}
if b.isMaxNodeLimit() {
tree.Truncation = tupleLimitTruncation(directResume(keysetpagination.PageToken{}))
return tree, nil
}
if restDepth <= 1 {
tree.Truncation = depthLimitTruncation(directResume(keysetpagination.PageToken{}))
return tree, nil
}
for nextPage, _ := keysetpagination.NewPaginator(); !nextPage.IsLast(); {
var tuples []*relationtuple.RelationTuple
var err error
tuples, nextPage, err = b.d.RelationTupleManager().GetRelationTuples(ctx, &relationtuple.RelationQuery{
Namespace: &subSet.Namespace,
Object: &subSet.Object,
Relation: &subSet.Relation,
},
append(nextPage.ToOptions(), paginatorOptionsFromResume(resume)...)...,
)
if err != nil {
return nil, err
} else if len(tuples) == 0 {
break
}
pageStart := len(tree.Children)
for _, tuple := range tuples {
if b.isStrict && rel != nil { // if b.isStrict then rel != nil as we check that before calling this function, but let's better not rely on that invariant
ss, ok := tuple.Subject.(*relationtuple.SubjectSet)
if !ok || !slices.ContainsFunc(rel.Types, func(rt ast.RelationType) bool {
return rt.Namespace == ss.Namespace && rt.Relation == ss.Relation
}) {
continue
}
}
childTree := newNode(ketoapi.TreeNodeLeaf, tuple.Subject)
if tupleSubSet, ok := tuple.Subject.(*relationtuple.SubjectSet); ok && tupleSubSet.Relation != "" {
childTree.Type = ketoapi.TreeNodeUnion
}
b.nodeCount++
tree.Children = append(tree.Children, childTree)
}
if b.isMaxNodeLimit() && !nextPage.IsLast() {
tree.Truncation = tupleLimitTruncation(directResume(nextPage.PageToken()))
}
// expand all the non-leaf nodes founds in this page
for _, child := range tree.Children[pageStart:] {
if child.Type != ketoapi.TreeNodeLeaf {
updated, err := b.buildTreeRecursive(ctx, child.Subject, restDepth-1)
if err != nil {
return nil, err
}
*child = *updated
}
}
if tree.Truncation != nil {
break
}
}
return tree, nil
}
// buildTreeForRewrite expands a subject set using an OPL-defined rewrite rule.
// OPL rewrite children are structural (never paginated); only data nodes (direct
// relation tuples fetched from the DB) can be paginated.
func (b *treeBuilder) buildTreeForRewrite(ctx context.Context, subSet *relationtuple.SubjectSet, rewrite ast.Child, restDepth int) (*relationtuple.Tree, error) {
switch rewrite := rewrite.(type) {
case *ast.SubjectSetRewrite:
tree := newNode(ketoapi.TreeNodeUnion, subSet)
if rewrite.Operation == ast.OperatorAnd {
tree.Type = ketoapi.TreeNodeIntersection
}
children := slices.SortedFunc(slices.Values(rewrite.Children), func(a, b ast.Child) int {
return rewriteCostOrder(a) - rewriteCostOrder(b)
})
for _, childRewrite := range children {
childTree, err := b.buildTreeForRewrite(ctx, subSet, childRewrite, restDepth)
if err != nil {
return nil, err
}
// SubjectSetRewrite followed by another SubjectSetRewrite means compound expression;
// The subject of the 2nd rewrite is not meaningful at that point.
// ex: view = A AND (B OR C) -> (B OR C) has no meaningul subject
if childTree != nil {
if _, ok := childRewrite.(*ast.SubjectSetRewrite); ok {
childTree.Subject = nil
}
tree.Children = append(tree.Children, childTree)
}
}
return tree, nil
case *ast.ComputedSubjectSet:
return b.buildTreeRecursive(ctx, &relationtuple.SubjectSet{
Namespace: subSet.Namespace,
Object: subSet.Object,
Relation: rewrite.Relation,
}, restDepth-1)
case *ast.TupleToSubjectSet:
return b.buildTreeTupleToSubjectSet(ctx, subSet, rewrite, nil, restDepth)
case *ast.InvertResult:
// inversion never has a meaningful Subject
invertNode := newNode(ketoapi.TreeNodeExclusion, nil)
childTree, err := b.buildTreeForRewrite(ctx, subSet, rewrite.Child, restDepth)
if err != nil {
return nil, err
}
if childTree != nil {
// any compound operation under an InvertResult also has no meaningful subject
// ex: view = !(A OR B)
if _, ok := rewrite.Child.(*ast.SubjectSetRewrite); ok {
childTree.Subject = nil
}
invertNode.Children = []*relationtuple.Tree{childTree}
}
return invertNode, nil
}
return nil, nil
}
// buildTreeTupleToSubjectSet finds subject sets under subSet#child.Relation, and for each,
// recurses into that subject set's child.ComputedSubjectSetRelation.
func (b *treeBuilder) buildTreeTupleToSubjectSet(ctx context.Context, subSet *relationtuple.SubjectSet, rewrite *ast.TupleToSubjectSet, resume *relationtuple.ExpandCursor, restDepth int) (*relationtuple.Tree, error) {
treeSubject := &relationtuple.SubjectSet{
Namespace: subSet.Namespace,
Object: subSet.Object,
Relation: rewrite.Relation,
}
tree := newNode(ketoapi.TreeNodeUnion, treeSubject)
ttuResume := func(pageToken keysetpagination.PageToken) *relationtuple.ExpandCursor {
return &relationtuple.ExpandCursor{Kind: relationtuple.ExpandCursorKindTTU, SubjectSet: treeSubject, TraverseRelation: &rewrite.ComputedSubjectSetRelation, NextPageToken: pageToken}
}
if b.isMaxNodeLimit() {
tree.Truncation = tupleLimitTruncation(ttuResume(keysetpagination.PageToken{}))
return tree, nil
}
if restDepth <= 1 {
tree.Truncation = depthLimitTruncation(ttuResume(keysetpagination.PageToken{}))
return tree, nil
}
for nextPage, _ := keysetpagination.NewPaginator(); !nextPage.IsLast(); {
var tuples []*relationtuple.RelationTuple
var err error
tuples, nextPage, err = b.d.RelationTupleManager().GetRelationTuples(ctx, &relationtuple.RelationQuery{
Namespace: &subSet.Namespace,
Object: &subSet.Object,
Relation: &rewrite.Relation,
},
append(nextPage.ToOptions(), paginatorOptionsFromResume(resume)...)...,
)
if err != nil {
return nil, err
} else if len(tuples) == 0 {
break
}
var toWorkOn []*relationtuple.RelationTuple
for _, tuple := range tuples {
_, ok := tuple.Subject.(*relationtuple.SubjectSet)
if !ok {
continue
}
b.nodeCount++
toWorkOn = append(toWorkOn, tuple)
}
if b.isMaxNodeLimit() && !nextPage.IsLast() {
tree.Truncation = tupleLimitTruncation(ttuResume(nextPage.PageToken()))
}
for _, tuple := range toWorkOn {
tupleSubSet := tuple.Subject.(*relationtuple.SubjectSet)
childTree, err := b.buildTreeRecursive(ctx, &relationtuple.SubjectSet{
Namespace: tupleSubSet.Namespace,
Object: tupleSubSet.Object,
Relation: rewrite.ComputedSubjectSetRelation,
}, restDepth-1)
if err != nil {
return nil, err
}
if childTree != nil {
tree.Children = append(tree.Children, childTree)
}
}
if tree.Truncation != nil {
break
}
}
return tree, nil
}
func tupleLimitTruncation(resume *relationtuple.ExpandCursor) *relationtuple.Truncation {
return &relationtuple.Truncation{Reason: relationtuple.TruncationReasonTupleLimit, Cursor: resume}
}
func depthLimitTruncation(resume *relationtuple.ExpandCursor) *relationtuple.Truncation {
return &relationtuple.Truncation{Reason: relationtuple.TruncationReasonDepthLimit, Cursor: resume}
}
func paginatorOptionsFromResume(resume *relationtuple.ExpandCursor) []keysetpagination.Option {
if resume != nil {
return []keysetpagination.Option{keysetpagination.WithToken(resume.NextPageToken)}
}
return nil
}
func newNode(typ ketoapi.TreeNodeType, subSet relationtuple.Subject) *relationtuple.Tree {
return &relationtuple.Tree{
Type: typ,
Subject: subSet,
}
}
func (b *treeBuilder) isMaxNodeLimit() bool {
return b.nodeCount >= b.maxNodes
}
// rewriteCostOrder returns a sort key for rewrite children so that cheaper,
// direct lookups are evaluated before more expensive traversals.
func rewriteCostOrder(c ast.Child) int {
switch c.(type) {
case *ast.ComputedSubjectSet:
return 0
case *ast.TupleToSubjectSet:
return 1
case *ast.SubjectSetRewrite:
return 2
case *ast.InvertResult:
return 3
default:
return 4
}
}

File diff suppressed because it is too large Load Diff

174
internal/expand/handler.go Normal file
View File

@@ -0,0 +1,174 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package expand
import (
"context"
"net/http"
"connectrpc.com/connect"
"github.com/ory/x/httprouterx"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"google.golang.org/protobuf/reflect/protoreflect"
"github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2/relationtuplesconnect"
"github.com/ory/keto/ketoapi"
"github.com/ory/herodot"
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/internal/x"
)
type (
handlerDependencies interface {
EngineProvider
relationtuple.ManagerProvider
relationtuple.MapperProvider
logrusx.Provider
httpx.WriterProvider
x.HandlerOptionsProvider
}
Handler struct {
relationtuplesconnect.UnimplementedExpandServiceHandler
d handlerDependencies
}
)
var (
_ relationtuplesconnect.ExpandServiceHandler = (*Handler)(nil)
_ *expandPermissions = nil
)
const RouteBase = "/relation-tuples/expand"
func NewHandler(d handlerDependencies) *Handler { return &Handler{d: d} }
func (h *Handler) RegisterReadRoutes(r *httprouterx.RouterPublic) {
r.GET(RouteBase, h.getExpand)
expandHandler := connect.NewUnaryHandler(
relationtuplesconnect.ExpandServiceExpandProcedure,
h.Expand,
connect.WithSchema(rts.File_ory_keto_relation_tuples_v1alpha2_expand_service_proto.
Services().ByName("ExpandService").
Methods().ByName("Expand")),
connect.WithHandlerOptions(h.d.HandlerOptions()...),
)
r.Handle(relationtuplesconnect.ExpandServiceExpandProcedure, expandHandler)
}
func (h *Handler) ProtoFiles() []protoreflect.FileDescriptor {
return []protoreflect.FileDescriptor{
rts.File_ory_keto_relation_tuples_v1alpha2_expand_service_proto,
rts.File_ory_keto_relation_tuples_v1alpha2_relation_tuples_proto,
}
}
// Expand Permissions Request Parameters
//
// swagger:parameters expandPermissions
type expandPermissions struct {
// in:query
MaxDepth int `json:"max-depth"`
// in:query
ketoapi.SubjectSet
}
// swagger:route GET /relation-tuples/expand permission expandPermissions
//
// # Expand a Relationship into permissions.
//
// Use this endpoint to expand a relationship tuple into permissions.
//
// Consumes:
// - application/x-www-form-urlencoded
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: expandedPermissionTree
// 400: errorGeneric
// 404: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: keto-admin-medium
func (h *Handler) getExpand(w http.ResponseWriter, r *http.Request) {
maxDepth, err := x.GetMaxDepthFromQuery(r.URL.Query())
if err != nil {
h.d.Writer().WriteError(w, r, herodot.ErrBadRequest().WithError(err.Error()))
return
}
subSet := (&ketoapi.SubjectSet{}).FromURLQuery(r.URL.Query())
internal, err := h.d.ReadOnlyMapper().FromSubjectSet(r.Context(), subSet)
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
res, err := h.d.ExpandEngine().BuildTree(r.Context(), internal, maxDepth)
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
if res == nil {
h.d.Writer().Write(w, r, herodot.ErrNotFound().WithError("no relation tuple found"))
return
}
tree, err := h.d.ReadOnlyMapper().ToTree(r.Context(), res)
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
h.d.Writer().Write(w, r, tree)
}
func (h *Handler) Expand(ctx context.Context, req *connect.Request[rts.ExpandRequest]) (*connect.Response[rts.ExpandResponse], error) {
var subSet *ketoapi.SubjectSet
switch sub := req.Msg.Subject.Ref.(type) {
case *rts.Subject_Id:
return connect.NewResponse(&rts.ExpandResponse{
Tree: &rts.SubjectTree{
NodeType: rts.NodeType_NODE_TYPE_LEAF,
Subject: rts.NewSubjectID(sub.Id),
},
}), nil
case *rts.Subject_Set:
subSet = &ketoapi.SubjectSet{
Namespace: sub.Set.Namespace,
Object: sub.Set.Object,
Relation: sub.Set.Relation,
}
}
internal, err := h.d.ReadOnlyMapper().FromSubjectSet(ctx, subSet)
if err != nil {
return nil, err
}
res, err := h.d.ExpandEngine().BuildTree(ctx, internal, int(req.Msg.MaxDepth))
if err != nil {
return nil, err
}
if res == nil {
return connect.NewResponse(&rts.ExpandResponse{}), nil
}
tree, err := h.d.ReadOnlyMapper().ToTree(ctx, res)
if err != nil {
return nil, err
}
return connect.NewResponse(&rts.ExpandResponse{Tree: tree.ToProto()}), nil
}

View File

@@ -0,0 +1,131 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package expand_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/ory/x/httprouterx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/keto/internal/driver"
"github.com/ory/keto/internal/expand"
"github.com/ory/keto/internal/namespace"
"github.com/ory/keto/internal/testhelpers"
"github.com/ory/keto/ketoapi"
)
func TestRESTHandler(t *testing.T) {
nspace := &namespace.Namespace{
Name: "expand handler",
}
reg := driver.NewSqliteTestRegistry(t, driver.WithNamespaces([]*namespace.Namespace{nspace}))
h := expand.NewHandler(reg)
r := httprouterx.NewRouterPublic()
h.RegisterReadRoutes(r)
ts := httptest.NewServer(r)
defer ts.Close()
t.Run("case=returns bad request on malformed int", func(t *testing.T) {
resp, err := ts.Client().Get(ts.URL + expand.RouteBase + "?max-depth=foo")
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Contains(t, string(body), "invalid syntax")
})
t.Run("case=returns not found on unknown namespace", func(t *testing.T) {
resp, err := ts.Client().Get(ts.URL + expand.RouteBase + "?" + url.Values{
"max-depth": {"10"},
"namespace": {"not " + nspace.Name},
}.Encode())
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Contains(t, string(body), "Unknown namespace")
})
t.Run("case=returns not found on unknown namespace", func(t *testing.T) {
resp, err := ts.Client().Get(ts.URL + expand.RouteBase + "?" + url.Values{
"max-depth": {"10"},
"namespace": {"not " + nspace.Name},
}.Encode())
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Contains(t, string(body), "Unknown namespace")
})
t.Run("case=returns expand tree", func(t *testing.T) {
rootSub := &ketoapi.SubjectSet{
Namespace: nspace.Name,
Object: "root",
Relation: "parent of",
}
expectedTree := &ketoapi.Tree[*ketoapi.RelationTuple]{
Type: ketoapi.TreeNodeUnion,
Tuple: &ketoapi.RelationTuple{
SubjectSet: rootSub,
},
Children: []*ketoapi.Tree[*ketoapi.RelationTuple]{
{
Type: ketoapi.TreeNodeLeaf,
Tuple: &ketoapi.RelationTuple{
SubjectID: new("child0"),
},
},
{
Type: ketoapi.TreeNodeLeaf,
Tuple: &ketoapi.RelationTuple{
SubjectID: new("child1"),
},
},
},
}
testhelpers.MapAndInsertTuples(t, reg,
&ketoapi.RelationTuple{
Namespace: nspace.Name,
Object: rootSub.Object,
Relation: rootSub.Relation,
SubjectID: expectedTree.Children[0].Tuple.SubjectID,
},
&ketoapi.RelationTuple{
Namespace: nspace.Name,
Object: rootSub.Object,
Relation: rootSub.Relation,
SubjectID: expectedTree.Children[1].Tuple.SubjectID,
},
)
qs := rootSub.ToURLQuery()
qs.Set("max-depth", "2")
resp, err := ts.Client().Get(ts.URL + expand.RouteBase + "?" + qs.Encode())
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
actualTree := ketoapi.Tree[*ketoapi.RelationTuple]{}
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
t.Logf("body: %s", string(body))
require.NoError(t, json.NewDecoder(bytes.NewBuffer(body)).Decode(&actualTree))
expand.AssertExternalTreesAreEqual(t, expectedTree, &actualTree)
})
}

View File

@@ -0,0 +1,127 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package expand
import (
"fmt"
"slices"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/ketoapi"
)
func AssertExternalTreesAreEqual(t *testing.T, expected, actual *ketoapi.Tree[*ketoapi.RelationTuple]) {
t.Helper()
assert.Truef(t, treesAreEqual(t, expected, actual),
"expected:\n%+v\n\nactual:\n%+v", expected, actual)
}
// TODO(hperl): Refactor to generic tree equality helper.
func treesAreEqual(t *testing.T, expected, actual *ketoapi.Tree[*ketoapi.RelationTuple]) bool {
if expected == nil || actual == nil {
return expected == actual
}
if expected.Type != actual.Type {
t.Logf("expected type %q, actual type %q", expected.Type, actual.Type)
return false
}
if !assert.ObjectsAreEqual(expected.Tuple.SubjectID, actual.Tuple.SubjectID) || !assert.ObjectsAreEqual(expected.Tuple.SubjectSet, actual.Tuple.SubjectSet) {
t.Logf("expected subject: %+v %+v, actual subject: %+v %+v", expected.Tuple.SubjectID, expected.Tuple.SubjectSet, actual.Tuple.SubjectID, actual.Tuple.SubjectSet)
return false
}
if len(expected.Children) != len(actual.Children) {
t.Logf("expected len(children)=%d, actual len(children)=%d", len(expected.Children), len(actual.Children))
return false
}
// For children, we check for equality disregarding the order
outer:
for _, expectedChild := range expected.Children {
for _, actualChild := range actual.Children {
if treesAreEqual(t, expectedChild, actualChild) {
continue outer
}
}
t.Logf("expected child:\n%+v\n\nactual child:\n%+v", expectedChild, actual)
return false
}
return true
}
// formatTree returns a human-readable, indented representation of a tree.
func formatTree(tree *relationtuple.Tree, indent string) string {
if tree == nil {
return indent + "<nil>\n"
}
var b strings.Builder
fmt.Fprintf(&b, "%s(%s) %+v", indent, tree.Type, tree.Subject)
if e := tree.Truncation; e != nil {
fmt.Fprintf(&b, " [truncation: reason=%s cursor=%s]", e.Reason, formatExpandCursor(e.Cursor))
}
b.WriteByte('\n')
for _, child := range tree.Children {
b.WriteString(formatTree(child, indent+" "))
}
return b.String()
}
func formatExpandCursor(c *relationtuple.ExpandCursor) string {
if c == nil {
return "<nil>"
}
var parts []string
parts = append(parts, "kind="+string(c.Kind))
parts = append(parts, "subject_set="+c.SubjectSet.String())
if c.TraverseRelation != nil {
parts = append(parts, "traverseRel="+*c.TraverseRelation)
}
return "{" + strings.Join(parts, " ") + "}"
}
// sortTree recursively sorts the children of a tree by subject string, in-place.
func sortTree(tree *relationtuple.Tree) {
if tree == nil {
return
}
for _, child := range tree.Children {
sortTree(child)
}
if tree.Children == nil {
return
}
slices.SortFunc(tree.Children, func(a, b *relationtuple.Tree) int {
if a.Subject == nil && b.Subject == nil {
return 0
}
if a.Subject == nil {
return 1
}
if b.Subject == nil {
return -1
}
return strings.Compare(a.Subject.String(), b.Subject.String())
})
}
// AssertInternalTreesAreEqual compares two trees for equality, ignoring child order.
func AssertInternalTreesAreEqual(t *testing.T, expected, actual *relationtuple.Tree) bool {
t.Helper()
sortTree(expected)
sortTree(actual)
return assert.Equal(t, formatTree(expected, ""), formatTree(actual, ""))
}
// RequireInternalTreesAreEqual compares two trees for equality, ignoring child order.
func RequireInternalTreesAreEqual(t *testing.T, expected, actual *relationtuple.Tree) {
t.Helper()
sortTree(expected)
sortTree(actual)
require.Equal(t, formatTree(expected, ""), formatTree(actual, ""))
}