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
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:
50
internal/check/trace/engine.go
Normal file
50
internal/check/trace/engine.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package trace
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ory/x/otelx"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/check/step"
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
"github.com/ory/keto/x/events"
|
||||
)
|
||||
|
||||
// Engine builds a runtime with tracing enabled and returns the debug Node
|
||||
// tree alongside the check result.
|
||||
type Engine struct {
|
||||
deps check.EngineDependencies
|
||||
}
|
||||
|
||||
func NewEngine(d check.EngineDependencies) *Engine {
|
||||
return &Engine{deps: d}
|
||||
}
|
||||
|
||||
func (e *Engine) d() check.EngineDependencies { return e.deps }
|
||||
|
||||
// CheckIsMemberWithTrace is like Engine.CheckIsMember but also returns a debug
|
||||
// tree that traces every sub-check performed.
|
||||
func (e *Engine) CheckIsMemberWithTrace(ctx context.Context, tuple *relationtuple.RelationTuple, restDepth int) (bool, *Node, error) {
|
||||
res, tree := e.CheckRelationTupleWithTrace(ctx, tuple, restDepth)
|
||||
return res.Membership == check.IsMember, tree, res.Err
|
||||
}
|
||||
|
||||
// CheckRelationTupleWithTrace returns the check result and the debug tree.
|
||||
func (e *Engine) CheckRelationTupleWithTrace(ctx context.Context, tuple *relationtuple.RelationTuple, restDepth int) (res check.Result, tree *Node) {
|
||||
ctx, span := e.d().Tracer(ctx).Tracer().Start(ctx, "Engine.CheckRelationTupleWithTrace")
|
||||
defer otelx.End(span, &res.Err)
|
||||
|
||||
session := NewSession()
|
||||
executor := step.NewExecutor(e.deps, session.Middleware(), step.CallLimitMiddleware())
|
||||
|
||||
res = executor.CheckRelationTuple(ctx, tuple, restDepth)
|
||||
session.Wait()
|
||||
if res.Err == nil {
|
||||
span.AddEvent(events.NewPermissionsChecked(ctx))
|
||||
}
|
||||
return res, session.Root()
|
||||
}
|
||||
607
internal/check/trace/engine_test.go
Normal file
607
internal/check/trace/engine_test.go
Normal file
@@ -0,0 +1,607 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package trace_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/keto/internal/check/trace"
|
||||
"github.com/ory/keto/internal/driver"
|
||||
"github.com/ory/keto/internal/driver/config"
|
||||
"github.com/ory/keto/internal/namespace"
|
||||
"github.com/ory/keto/internal/testhelpers"
|
||||
"github.com/ory/keto/schema"
|
||||
)
|
||||
|
||||
// # Non-determinism constraint
|
||||
//
|
||||
// Expand and traverse (tuple-to-subject-set) children are ordered by
|
||||
// shard_id (random UUIDv4) and require sortNode normalization.
|
||||
//
|
||||
// As a result, we cannot write tests where multiple Expand or Traverse siblings
|
||||
// compete to produce member; which one wins first is non-deterministic, as the
|
||||
// child tuples are not executed in the order we create them.
|
||||
// Therefore:
|
||||
// - sortNode is used to sort the children of an Expand or Traverse nodes
|
||||
// - Tests where all branches return not-member are always safe: every branch
|
||||
// runs to completion before not-member is returned.
|
||||
// - Do not write tests where Expand or Traverse has multiple children, and one child
|
||||
// returns member, the other one notMember; which one runs first is non-deterministic;
|
||||
|
||||
type testcase struct {
|
||||
name string
|
||||
opl string
|
||||
inputTuples []string
|
||||
maxDepth int
|
||||
|
||||
checkInput string
|
||||
expected func(t testing.TB) *trace.Node
|
||||
strict bool
|
||||
only bool
|
||||
}
|
||||
|
||||
func TestTraceEngine(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []testcase{
|
||||
// ── no-OPL mode ──────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
// Engine finds the tuple immediately via direct lookup.
|
||||
// Expand is registered eagerly, but the Executor already has a decisive
|
||||
// result from the direct match, so the expansion work is skipped.
|
||||
name: "no-OPL: direct hit",
|
||||
inputTuples: []string{
|
||||
"Group:eng#members@User:1",
|
||||
},
|
||||
checkInput: "Group:eng#members@User:1",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, member, "Group:eng#members@User:1",
|
||||
directNode(t, member, "Group:eng#members@User:1"),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Engine follows a three-hop SubjectSet chain to find a member.
|
||||
name: "no-OPL: multi-hop SubjectSet expansion",
|
||||
inputTuples: []string{
|
||||
"Group:a#members@User:1",
|
||||
"Group:b#members@Group:a#members",
|
||||
"Group:c#members@Group:b#members",
|
||||
},
|
||||
checkInput: "Group:c#members@User:1",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, member, "Group:c#members@User:1",
|
||||
directNode(t, notMember, "Group:c#members@User:1"),
|
||||
expandNode(t, member, "Group:c#members@User:1", 1,
|
||||
checkNode(t, member, "Group:b#members@User:1",
|
||||
expandNode(t, member, "Group:b#members@User:1", 1,
|
||||
foundNode(t, "Group:a#members@User:1"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Engine hits the depth cap at restDepth=0 and returns unknown.
|
||||
name: "no-OPL: depth limit stops expansion",
|
||||
maxDepth: 2,
|
||||
inputTuples: []string{
|
||||
"Group:a#members@User:1",
|
||||
"Group:b1#members@Group:a#members",
|
||||
"Group:c#members@Group:b1#members",
|
||||
},
|
||||
checkInput: "Group:c#members@User:99",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, unknown, "Group:c#members@User:99",
|
||||
directNode(t, notMember, "Group:c#members@User:99"),
|
||||
expandNode(t, unknown, "Group:c#members@User:99", 1,
|
||||
checkNode(t, unknown, "Group:b1#members@User:99",
|
||||
expandNode(t, unknown, "Group:b1#members@User:99", 1),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
// ── strict OPL mode ───────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
// In strict mode the only type-safe checks are fired;
|
||||
// no useless direct/expand on relations that are "permit";
|
||||
// Subject-set fires only when OPL field declares a SubjectSet<..>;
|
||||
name: "strict OPL: SubjectSet expand fires only for declared types",
|
||||
strict: true,
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
members: User[]
|
||||
}
|
||||
}
|
||||
class File implements Namespace {
|
||||
related: {
|
||||
viewers: SubjectSet<Group, "members">[]
|
||||
}
|
||||
permits = {
|
||||
view: (ctx: Context) => this.related.viewers.includes(ctx.subject)
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"File:f1#viewers@Group:g1#members",
|
||||
},
|
||||
checkInput: "File:f1#view@User:u1",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, notMember, "File:f1#view@User:u1",
|
||||
unionNode(t, notMember, "File:f1#view@User:u1",
|
||||
// viewers only declares SubjectSet<Group,"members">, so User:u1 can
|
||||
// never be a direct match, so no direct or multi-direct check runs.
|
||||
checkNode(t, notMember, "File:f1#viewers@User:u1",
|
||||
// expand fires because viewers declares SubjectSet<Group,"members">.
|
||||
// expand found 1 node, which is Group:g1#members, which is not a direct hit → not-member.
|
||||
expandNode(t, notMember, "File:f1#viewers@User:u1", 1,
|
||||
// skipDirect=true + no SubjectSet types on members → empty check
|
||||
checkNode(t, notMember, "Group:g1#members@User:u1"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Strict OR with multiple computed-usersets and a TTU.
|
||||
// The OR shortcut batches all plain computed-usersets into a single multiDirect check.
|
||||
// Plain related fields (viewers, viewers2) with no SubjectSet types produce
|
||||
// empty checks because multiDirect already covers them, and sets skipDirect=true.
|
||||
name: "strict OPL: OR batches computed-usersets, suppresses spurious permit checks",
|
||||
strict: true,
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
members: User[]
|
||||
}
|
||||
}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
viewers: User[]
|
||||
viewers2: User[]
|
||||
viewerGroups: Group[]
|
||||
}
|
||||
permits = {
|
||||
view: (ctx: Context) => this.related.viewers.includes(ctx.subject) ||
|
||||
this.related.viewerGroups.traverse(g => g.related.members.includes(ctx.subject)) ||
|
||||
this.related.viewers2.includes(ctx.subject)
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"File:f1#viewers@User:1",
|
||||
"File:f1#viewers@User:2",
|
||||
|
||||
"File:f1#viewers2@User:10",
|
||||
"File:f1#viewers2@User:11",
|
||||
|
||||
"File:f1#viewerGroups@Group:g1",
|
||||
"Group:g1#members@User:4",
|
||||
"Group:g1#members@User:5",
|
||||
},
|
||||
checkInput: "File:f1#view@User:100",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, notMember, "File:f1#view@User:100",
|
||||
unionNode(t, notMember, "File:f1#view@User:100",
|
||||
// OR shortcut batches viewers + viewers2 into one multi-relation direct check.
|
||||
multiDirectNode(t, notMember, "File:f1#view@User:100", []string{"viewers", "viewers2"}),
|
||||
// viewers and viewers2 are plain User[] fields: empty checks in strict mode.
|
||||
checkNode(t, notMember, "File:f1#viewers@User:100"),
|
||||
checkNode(t, notMember, "File:f1#viewers2@User:100"),
|
||||
// TTU traverses viewerGroups; Group:g1 has no User:100 → not-member.
|
||||
traverseNode(t, notMember, "File:f1#viewerGroups@User:100", 1,
|
||||
checkNode(t, notMember, "Group:g1#members@User:100",
|
||||
directNode(t, notMember, "Group:g1#members@User:100"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Strict OR where the TTU branch finds a member.
|
||||
name: "strict OPL: OR with TTU finds member",
|
||||
strict: true,
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
members: User[]
|
||||
}
|
||||
}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
viewers: User[]
|
||||
viewerGroups: Group[]
|
||||
}
|
||||
permits = {
|
||||
view: (ctx: Context) => this.related.viewers.includes(ctx.subject) ||
|
||||
this.related.viewerGroups.traverse(g => g.related.members.includes(ctx.subject))
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"File:f1#viewers@User:1",
|
||||
"File:f1#viewers@User:2",
|
||||
"File:f1#viewers@User:3",
|
||||
|
||||
"File:f1#viewerGroups@Group:g1",
|
||||
"Group:g1#members@User:4",
|
||||
},
|
||||
checkInput: "File:f1#view@User:4",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, member, "File:f1#view@User:4",
|
||||
unionNode(t, member, "File:f1#view@User:4",
|
||||
// multiDirect for viewers: User:4 is not a direct viewer.
|
||||
multiDirectNode(t, notMember, "File:f1#view@User:4", []string{"viewers"}),
|
||||
// viewers is User[]: empty check in strict+skipDirect.
|
||||
checkNode(t, notMember, "File:f1#viewers@User:4"),
|
||||
// TTU loads Group:g1; User:4 is a direct member → member.
|
||||
// strict: members is User[] (no SubjectSet types) → no expand registered.
|
||||
traverseNode(t, member, "File:f1#viewerGroups@User:4", 1,
|
||||
checkNode(t, member, "Group:g1#members@User:4",
|
||||
directNode(t, member, "Group:g1#members@User:4"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// NOT inverts an inner member result to not-member.
|
||||
name: "strict OPL: NOT inverts member to not-member",
|
||||
strict: true,
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
blocklist: User[]
|
||||
}
|
||||
permits = {
|
||||
view: (ctx: Context) => !this.related.blocklist.includes(ctx.subject)
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"File:f1#blocklist@User:blocked",
|
||||
},
|
||||
checkInput: "File:f1#view@User:blocked",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, notMember, "File:f1#view@User:blocked",
|
||||
unionNode(t, notMember, "File:f1#view@User:blocked",
|
||||
invertNode(t, notMember, "File:f1#view@User:blocked",
|
||||
computedNode(t, member, "File:f1#blocklist@User:blocked",
|
||||
checkNode(t, member, "File:f1#blocklist@User:blocked",
|
||||
directNode(t, member, "File:f1#blocklist@User:blocked"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// NOT inverts an inner not-member result to member.
|
||||
name: "strict OPL: NOT inverts not-member to member",
|
||||
strict: true,
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
blocklist: User[]
|
||||
}
|
||||
permits = {
|
||||
view: (ctx: Context) => !this.related.blocklist.includes(ctx.subject)
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"File:f1#blocklist@User:blocked",
|
||||
},
|
||||
checkInput: "File:f1#view@User:free",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, member, "File:f1#view@User:free",
|
||||
unionNode(t, member, "File:f1#view@User:free",
|
||||
invertNode(t, member, "File:f1#view@User:free",
|
||||
computedNode(t, notMember, "File:f1#blocklist@User:free",
|
||||
checkNode(t, notMember, "File:f1#blocklist@User:free",
|
||||
directNode(t, notMember, "File:f1#blocklist@User:free"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "strict OPL: TTU with permit call inside traverse",
|
||||
strict: true,
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
members: User[]
|
||||
}
|
||||
permits = {
|
||||
isMember: (ctx: Context) => this.related.members.includes(ctx.subject)
|
||||
}
|
||||
}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
viewerGroups: Group[]
|
||||
}
|
||||
permits = {
|
||||
view: (ctx: Context) => this.related.viewerGroups.traverse(g => g.permits.isMember(ctx))
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"Group:g1#members@User:1",
|
||||
"Group:g1#members@User:2",
|
||||
"File:f1#viewerGroups@Group:g1",
|
||||
},
|
||||
checkInput: "File:f1#view@User:99",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, notMember, "File:f1#view@User:99",
|
||||
unionNode(t, notMember, "File:f1#view@User:99",
|
||||
traverseNode(t, notMember, "File:f1#viewerGroups@User:99", 1,
|
||||
checkNode(t, notMember, "Group:g1#isMember@User:99",
|
||||
unionNode(t, notMember, "Group:g1#isMember@User:99",
|
||||
multiDirectNode(t, notMember, "Group:g1#members@User:99", []string{"members"}),
|
||||
checkNode(t, notMember, "Group:g1#members@User:99"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
// ── non-strict OPL mode ───────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
// Non-strict mode fires the rewrite, a direct check, AND a subject-set
|
||||
// expand concurrently even for a permit relation (view).
|
||||
name: "non-strict OPL: OR on permit reference emits spurious direct and expand",
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
members: User[]
|
||||
}
|
||||
}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
viewers: User[]
|
||||
viewerGroups: Group[]
|
||||
}
|
||||
permits = {
|
||||
viewGroup: (ctx: Context) => this.related.viewerGroups.traverse(g => g.related.members.includes(ctx.subject)),
|
||||
view: (ctx: Context) => this.related.viewers.includes(ctx.subject) || this.permits.viewGroup(ctx)
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"File:f1#viewers@User:1",
|
||||
"File:f1#viewers@User:2",
|
||||
"File:f1#viewers@User:3",
|
||||
|
||||
"Group:g1#members@User:4",
|
||||
"Group:g1#members@User:5",
|
||||
"Group:g2#members@User:6",
|
||||
"Group:g2#members@User:7",
|
||||
"Group:g3#members@User:8",
|
||||
"Group:g3#members@User:9",
|
||||
|
||||
"File:f1#viewerGroups@Group:g1",
|
||||
"File:f1#viewerGroups@Group:g2",
|
||||
"File:f1#viewerGroups@Group:g3",
|
||||
},
|
||||
checkInput: "File:f1#view@User:100",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
leafCheck := func(s string) *trace.Node {
|
||||
return checkNode(t, notMember, s,
|
||||
expandNode(t, notMember, s, 0),
|
||||
)
|
||||
}
|
||||
groupCheck := func(g string, users ...string) *trace.Node {
|
||||
members := make([]*trace.Node, len(users))
|
||||
for i, u := range users {
|
||||
members[i] = leafCheck("User:" + u + "#@User:100")
|
||||
}
|
||||
return checkNode(t, notMember, "Group:"+g+"#members@User:100",
|
||||
directNode(t, notMember, "Group:"+g+"#members@User:100"),
|
||||
expandNode(t, notMember, "Group:"+g+"#members@User:100", len(users), members...),
|
||||
)
|
||||
}
|
||||
return checkNode(t, notMember, "File:f1#view@User:100",
|
||||
unionNode(t, notMember, "File:f1#view@User:100",
|
||||
multiDirectNode(t, notMember, "File:f1#view@User:100", []string{"viewers", "viewGroup"}),
|
||||
checkNode(t, notMember, "File:f1#viewers@User:100",
|
||||
expandNode(t, notMember, "File:f1#viewers@User:100", 3,
|
||||
leafCheck("User:1#@User:100"),
|
||||
leafCheck("User:2#@User:100"),
|
||||
leafCheck("User:3#@User:100"),
|
||||
),
|
||||
),
|
||||
checkNode(t, notMember, "File:f1#viewGroup@User:100",
|
||||
unionNode(t, notMember, "File:f1#viewGroup@User:100",
|
||||
traverseNode(t, notMember, "File:f1#viewerGroups@User:100", 3,
|
||||
groupCheck("g1", "4", "5"),
|
||||
groupCheck("g2", "6", "7"),
|
||||
groupCheck("g3", "8", "9"),
|
||||
),
|
||||
),
|
||||
expandNode(t, notMember, "File:f1#viewGroup@User:100", 0),
|
||||
),
|
||||
),
|
||||
directNode(t, notMember, "File:f1#view@User:100"),
|
||||
expandNode(t, notMember, "File:f1#view@User:100", 0),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// AND: both arms must complete before the result is known, so
|
||||
// the full tree is always explored regardless of intermediate results.
|
||||
name: "non-strict OPL: AND requires both arms; permit-calls-permit",
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
members: User[]
|
||||
}
|
||||
}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
viewers: User[]
|
||||
viewerGroups: Group[]
|
||||
}
|
||||
permits = {
|
||||
viewGroup: (ctx: Context) => this.related.viewerGroups.traverse(g => g.related.members.includes(ctx.subject)),
|
||||
|
||||
view: (ctx: Context) => this.related.viewers.includes(ctx.subject) &&
|
||||
this.permits.viewGroup(ctx)
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"File:f1#viewers@User:1",
|
||||
"File:f1#viewers@User:2",
|
||||
"File:f1#viewers@User:3",
|
||||
|
||||
"Group:g1#members@User:4",
|
||||
"Group:g1#members@User:5",
|
||||
"Group:g2#members@User:6",
|
||||
"Group:g2#members@User:7",
|
||||
"Group:g3#members@User:8",
|
||||
"Group:g3#members@User:9",
|
||||
|
||||
"File:f1#viewerGroups@Group:g1",
|
||||
"File:f1#viewerGroups@Group:g2",
|
||||
"File:f1#viewerGroups@Group:g3",
|
||||
},
|
||||
checkInput: "File:f1#view@User:1",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
leafCheck := func(s string) *trace.Node {
|
||||
return checkNode(t, notMember, s,
|
||||
expandNode(t, notMember, s, 0),
|
||||
)
|
||||
}
|
||||
groupCheck := func(g string, users ...string) *trace.Node {
|
||||
members := make([]*trace.Node, len(users))
|
||||
for i, u := range users {
|
||||
members[i] = leafCheck("User:" + u + "#@User:1")
|
||||
}
|
||||
|
||||
return checkNode(t, notMember, "Group:"+g+"#members@User:1",
|
||||
directNode(t, notMember, "Group:"+g+"#members@User:1"),
|
||||
expandNode(t, notMember, "Group:"+g+"#members@User:1", len(users), members...),
|
||||
)
|
||||
}
|
||||
return checkNode(t, notMember, "File:f1#view@User:1",
|
||||
intersectionNode(t, notMember, "File:f1#view@User:1",
|
||||
computedNode(t, member, "File:f1#viewers@User:1",
|
||||
checkNode(t, member, "File:f1#viewers@User:1",
|
||||
directNode(t, member, "File:f1#viewers@User:1"),
|
||||
),
|
||||
),
|
||||
computedNode(t, notMember, "File:f1#viewGroup@User:1",
|
||||
checkNode(t, notMember, "File:f1#viewGroup@User:1",
|
||||
unionNode(t, notMember, "File:f1#viewGroup@User:1",
|
||||
traverseNode(t, notMember, "File:f1#viewerGroups@User:1", 3,
|
||||
groupCheck("g1", "4", "5"),
|
||||
groupCheck("g2", "6", "7"),
|
||||
groupCheck("g3", "8", "9"),
|
||||
),
|
||||
),
|
||||
directNode(t, notMember, "File:f1#viewGroup@User:1"),
|
||||
expandNode(t, notMember, "File:f1#viewGroup@User:1", 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
directNode(t, notMember, "File:f1#view@User:1"),
|
||||
expandNode(t, notMember, "File:f1#view@User:1", 0),
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
focused := slices.ContainsFunc(tests, func(tt testcase) bool { return tt.only })
|
||||
for _, tt := range tests {
|
||||
if focused && !tt.only {
|
||||
continue
|
||||
}
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
opts := []driver.TestRegistryOption{driver.WithLogLevel("info")}
|
||||
|
||||
if tt.opl != "" {
|
||||
_, errs := schema.Parse(tt.opl)
|
||||
require.Empty(t, errs, "invalid OPL in test case: %v", errs)
|
||||
}
|
||||
|
||||
if tt.opl != "" {
|
||||
opts = append(opts, driver.WithOPL(tt.opl))
|
||||
} else {
|
||||
opts = append(opts, driver.WithConfig(config.KeyNamespaces, []*namespace.Namespace{}))
|
||||
}
|
||||
|
||||
if tt.maxDepth != 0 {
|
||||
opts = append(opts, driver.WithConfig(config.KeyLimitMaxReadDepth, tt.maxDepth))
|
||||
}
|
||||
|
||||
if tt.strict {
|
||||
opts = append(opts, driver.WithConfig(config.KeyFeatureFlagStrictMode, tt.strict))
|
||||
}
|
||||
|
||||
reg := driver.NewSqliteTestRegistry(t, opts...)
|
||||
e := trace.NewEngine(reg)
|
||||
|
||||
ctx := context.Background()
|
||||
for _, row := range tt.inputTuples {
|
||||
require.NoError(t, reg.RelationTupleManager().WriteRelationTuples(ctx, testhelpers.TupleFromString(t, row)))
|
||||
}
|
||||
|
||||
_, tree, err := e.CheckIsMemberWithTrace(ctx, testhelpers.TupleFromString(t, tt.checkInput), 100)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := trace.SortNode(tt.expected(t))
|
||||
actual := trace.SortNode(trace.StripTiming(tree))
|
||||
require.Equal(t, expected.String(), actual.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// result helpers — used as the first argument to every node builder.
|
||||
const (
|
||||
member = trace.ResultMember
|
||||
notMember = trace.ResultNotMember
|
||||
unknown = trace.ResultUnknown
|
||||
)
|
||||
|
||||
// Local aliases for the exported helpers in trace so test cases stay concise.
|
||||
var (
|
||||
checkNode = trace.CheckNode
|
||||
directNode = trace.DirectNode
|
||||
expandNode = trace.ExpandNode
|
||||
foundNode = trace.FoundNode
|
||||
unionNode = trace.UnionNode
|
||||
intersectionNode = trace.IntersectionNode
|
||||
invertNode = trace.InvertNode
|
||||
computedNode = trace.ComputedNode
|
||||
traverseNode = trace.TraverseNode
|
||||
multiDirectNode = trace.MultiDirectNode
|
||||
)
|
||||
199
internal/check/trace/node.go
Normal file
199
internal/check/trace/node.go
Normal file
@@ -0,0 +1,199 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package trace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
)
|
||||
|
||||
type (
|
||||
NodeKind string
|
||||
NodeResult string
|
||||
)
|
||||
|
||||
const (
|
||||
NodeIsAllowed NodeKind = "is_allowed"
|
||||
NodeDirect NodeKind = "direct"
|
||||
NodeMultiDirect NodeKind = "direct_multi"
|
||||
NodeExpandSubject NodeKind = "expand_subject"
|
||||
NodeFoundSubject NodeKind = "expand_found"
|
||||
NodeComputed NodeKind = "computed"
|
||||
NodeTraverse NodeKind = "traverse"
|
||||
NodeUnion NodeKind = "union"
|
||||
NodeIntersection NodeKind = "intersection"
|
||||
NodeInvert NodeKind = "invert"
|
||||
)
|
||||
|
||||
func (k NodeKind) String() string { return string(k) }
|
||||
|
||||
const (
|
||||
// ResultMember indicates the subject is a member.
|
||||
ResultMember NodeResult = "member"
|
||||
// ResultNotMember indicates the subject is not a member.
|
||||
ResultNotMember NodeResult = "not_member"
|
||||
// ResultError indicates an error occurred during the check.
|
||||
ResultError NodeResult = "error"
|
||||
// ResultSkipped indicates the check was registered but never executed
|
||||
// because a sibling check resolved the parent before this one ran.
|
||||
ResultSkipped NodeResult = "skipped"
|
||||
// ResultCancelled indicates the check was cancelled mid-execution
|
||||
// because a sibling check resolved.
|
||||
ResultCancelled NodeResult = "cancelled"
|
||||
// ResultUnknown indicates the check was cut short because restDepth
|
||||
// reached zero; membership could not be determined.
|
||||
ResultUnknown NodeResult = "unknown"
|
||||
)
|
||||
|
||||
func (r NodeResult) prunable() bool {
|
||||
return r == ResultNotMember || r == ResultSkipped
|
||||
}
|
||||
|
||||
// MaxExpandChildren is the maximum number of non-significant children kept on
|
||||
// expand_subject and traverse nodes. Children whose result is member, error,
|
||||
// or cancelled are always kept regardless of this limit.
|
||||
const MaxExpandChildren = 5
|
||||
|
||||
// Node is one step in the debug tree produced by a permission check. It
|
||||
// records what kind of operation was performed, what the input tuple was, what
|
||||
// the result was, how many database records were fetched, and any sub-steps
|
||||
// that were spawned.
|
||||
type Node struct {
|
||||
Kind NodeKind
|
||||
Tuple *relationtuple.RelationTuple
|
||||
Result NodeResult
|
||||
|
||||
// Relations holds the batched relation names for NodeMultiDirect nodes.
|
||||
Relations []string
|
||||
|
||||
// TuplesLoaded is the number of database records fetched by this node.
|
||||
TuplesLoaded int
|
||||
|
||||
// TruncatedChildren is the number of children that were dropped to keep
|
||||
// the tree size manageable.
|
||||
TruncatedChildren int
|
||||
|
||||
Children []*Node
|
||||
|
||||
// Duration is the wall-clock time spent in this node (including children).
|
||||
Duration time.Duration
|
||||
|
||||
// parent is set when this node is appended to a parent, so that
|
||||
// dropIfOverLimit can remove it after the result is known.
|
||||
parent *Node
|
||||
|
||||
// mu protects Children, TruncatedChildren, TuplesLoaded, and prunableCount.
|
||||
mu sync.Mutex
|
||||
|
||||
// prunableCount counts how many children have a prunable result.
|
||||
prunableCount int
|
||||
}
|
||||
|
||||
func (n *Node) setResult(result check.Result) {
|
||||
if result.Err != nil {
|
||||
n.Result = ResultError
|
||||
if errors.Is(result.Err, context.Canceled) {
|
||||
n.Result = ResultCancelled
|
||||
}
|
||||
return
|
||||
}
|
||||
switch result.Membership {
|
||||
case check.IsMember:
|
||||
n.Result = ResultMember
|
||||
case check.NotMember:
|
||||
n.Result = ResultNotMember
|
||||
default:
|
||||
n.Result = ResultUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// dropIfOverLimit removes this node from its parent when the parent is an
|
||||
// expand or TTU node that already has MaxExpandChildren prunable children.
|
||||
func (n *Node) dropIfOverLimit() {
|
||||
parent := n.parent
|
||||
if parent == nil {
|
||||
return
|
||||
}
|
||||
if parent.Kind != NodeExpandSubject && parent.Kind != NodeTraverse {
|
||||
return
|
||||
}
|
||||
if !n.Result.prunable() {
|
||||
return
|
||||
}
|
||||
parent.mu.Lock()
|
||||
defer parent.mu.Unlock()
|
||||
parent.prunableCount++
|
||||
if parent.prunableCount <= MaxExpandChildren {
|
||||
return
|
||||
}
|
||||
for i, c := range parent.Children {
|
||||
if c == n {
|
||||
parent.Children = append(parent.Children[:i], parent.Children[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
parent.TruncatedChildren++
|
||||
}
|
||||
|
||||
func (n *Node) recordTuplesLoaded(count int) {
|
||||
n.mu.Lock()
|
||||
n.TuplesLoaded += count
|
||||
n.mu.Unlock()
|
||||
}
|
||||
|
||||
func (n *Node) recordFoundSubject(tuple *relationtuple.RelationTuple) {
|
||||
child := &Node{Kind: NodeFoundSubject, Tuple: tuple, Result: ResultMember}
|
||||
n.mu.Lock()
|
||||
n.Children = append(n.Children, child)
|
||||
n.mu.Unlock()
|
||||
}
|
||||
|
||||
// String is a human-friendly representation of the debug tree, useful during testing.
|
||||
func (n *Node) String() string {
|
||||
if n == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
var walk func(cur *Node, depth int)
|
||||
walk = func(cur *Node, depth int) {
|
||||
for range depth {
|
||||
b.WriteString(" ")
|
||||
}
|
||||
|
||||
input := ""
|
||||
if cur.Tuple != nil {
|
||||
input = cur.Tuple.String()
|
||||
if len(cur.Relations) > 0 {
|
||||
input = strings.Replace(input, "#"+cur.Tuple.Relation+"@", "#{"+strings.Join(cur.Relations, ",")+"}"+"@", 1)
|
||||
}
|
||||
}
|
||||
b.WriteString(string(cur.Kind))
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(strconv.Quote(input))
|
||||
b.WriteString(" -> ")
|
||||
b.WriteString(string(cur.Result))
|
||||
|
||||
if cur.TuplesLoaded > 0 {
|
||||
b.WriteString(" tuples_loaded=")
|
||||
b.WriteString(strconv.Itoa(cur.TuplesLoaded))
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
|
||||
for _, child := range cur.Children {
|
||||
walk(child, depth+1)
|
||||
}
|
||||
}
|
||||
|
||||
walk(n, 0)
|
||||
return strings.TrimSuffix(b.String(), "\n")
|
||||
}
|
||||
147
internal/check/trace/session.go
Normal file
147
internal/check/trace/session.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package trace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/check/step"
|
||||
"github.com/ory/keto/internal/namespace/ast"
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
)
|
||||
|
||||
// traceNodeKey is the context key used to store and retrieve the current
|
||||
// trace node as execution descends through the step tree.
|
||||
type traceNodeKey struct{}
|
||||
|
||||
func nodeFromCtx(ctx context.Context) *Node {
|
||||
n, _ := ctx.Value(traceNodeKey{}).(*Node)
|
||||
return n
|
||||
}
|
||||
|
||||
func withTraceNode(ctx context.Context, n *Node) context.Context {
|
||||
return context.WithValue(ctx, traceNodeKey{}, n)
|
||||
}
|
||||
|
||||
// Session collects the debug tree for a single runtime execution.
|
||||
type Session struct {
|
||||
mu sync.Mutex
|
||||
root *Node
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewSession() *Session {
|
||||
return &Session{}
|
||||
}
|
||||
|
||||
func (t *Session) Root() *Node {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.root
|
||||
}
|
||||
|
||||
func (t *Session) Wait() {
|
||||
t.wg.Wait()
|
||||
}
|
||||
|
||||
// nodeKindForStep maps a Step's execution-model kind to the trace-model NodeKind.
|
||||
func nodeKindForStep(s check.Step) NodeKind {
|
||||
if s, ok := s.(step.RewriteStep); ok {
|
||||
if s.Rewrite.Operation == ast.OperatorOr {
|
||||
return NodeUnion
|
||||
}
|
||||
return NodeIntersection
|
||||
}
|
||||
switch s.Kind() {
|
||||
case check.StepIsAllowed:
|
||||
return NodeIsAllowed
|
||||
case check.StepDirect:
|
||||
return NodeDirect
|
||||
case check.StepDirectMulti:
|
||||
return NodeMultiDirect
|
||||
case check.StepExpand:
|
||||
return NodeExpandSubject
|
||||
case check.StepComputed:
|
||||
return NodeComputed
|
||||
case check.StepTraverse:
|
||||
return NodeTraverse
|
||||
case check.StepInvert:
|
||||
return NodeInvert
|
||||
default:
|
||||
return NodeKind(s.Kind())
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware returns a Middleware that records a Node for every step execution,
|
||||
// building the debug trace tree as execution unfolds.
|
||||
func (t *Session) Middleware() check.Middleware {
|
||||
return func(ctx context.Context, s check.Step, req check.CheckRequest, next func(context.Context, check.Step, check.CheckRequest) check.Result) check.Result {
|
||||
t.wg.Add(1)
|
||||
defer t.wg.Done()
|
||||
|
||||
tuple := req.Tuple
|
||||
var relations []string
|
||||
|
||||
switch s := s.(type) {
|
||||
case step.ComputedUsersetStep:
|
||||
clone := *req.Tuple
|
||||
clone.Relation = s.Relation
|
||||
tuple = &clone
|
||||
case step.TraverseStep:
|
||||
clone := *req.Tuple
|
||||
clone.Relation = s.TTU.Relation
|
||||
tuple = &clone
|
||||
case step.DirectMultiStep:
|
||||
relations = s.Relations
|
||||
}
|
||||
|
||||
node, ctx := t.newTraceNode(ctx, nodeKindForStep(s), tuple, relations...)
|
||||
switch s.Kind() {
|
||||
case check.StepExpand:
|
||||
ctx = check.WithTraceHooks(ctx, check.TraceHooks{
|
||||
TuplesLoaded: node.recordTuplesLoaded,
|
||||
SubjectFound: node.recordFoundSubject,
|
||||
})
|
||||
case check.StepTraverse:
|
||||
ctx = check.WithTraceHooks(ctx, check.TraceHooks{TuplesLoaded: node.recordTuplesLoaded})
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
result := next(ctx, s, req)
|
||||
node.Duration = time.Since(start)
|
||||
node.setResult(result)
|
||||
node.dropIfOverLimit()
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// newTraceNode creates a Node, links it as a child of the current parent node
|
||||
// in ctx (or sets it as the root if there is none), and returns both the node
|
||||
// and a derived context with the new node set as the current parent.
|
||||
func (t *Session) newTraceNode(ctx context.Context, kind NodeKind, tuple *relationtuple.RelationTuple, relations ...string) (*Node, context.Context) {
|
||||
node := &Node{
|
||||
Kind: kind,
|
||||
Tuple: tuple,
|
||||
Relations: relations,
|
||||
|
||||
// Initial Result is Skipped, as it's possible that the step won't run at all
|
||||
// due to other sibling branch finishing with success.
|
||||
Result: ResultSkipped,
|
||||
}
|
||||
if parent := nodeFromCtx(ctx); parent != nil {
|
||||
node.parent = parent
|
||||
parent.mu.Lock()
|
||||
parent.Children = append(parent.Children, node)
|
||||
parent.mu.Unlock()
|
||||
} else {
|
||||
t.mu.Lock()
|
||||
t.root = node
|
||||
t.mu.Unlock()
|
||||
}
|
||||
return node, withTraceNode(ctx, node)
|
||||
}
|
||||
115
internal/check/trace/testhelpers.go
Normal file
115
internal/check/trace/testhelpers.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package trace
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/keto/internal/testhelpers"
|
||||
)
|
||||
|
||||
// SortNode sorts children of NodeExpandSubject and NodeTraverse nodes by
|
||||
// Tuple.String() recursively. Both node kinds have non-deterministic child
|
||||
// order because the underlying queries order by shard_id (a random UUIDv4
|
||||
// assigned at write time). All other sibling orderings are engine-controlled
|
||||
// and must be asserted as-is.
|
||||
func SortNode(n *Node) *Node {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
if n.Kind == NodeExpandSubject || n.Kind == NodeTraverse {
|
||||
slices.SortFunc(n.Children, func(a, b *Node) int {
|
||||
as, bs := "", ""
|
||||
if a.Tuple != nil {
|
||||
as = a.Tuple.String()
|
||||
}
|
||||
if b.Tuple != nil {
|
||||
bs = b.Tuple.String()
|
||||
}
|
||||
if c := strings.Compare(as, bs); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(string(a.Kind), string(b.Kind))
|
||||
})
|
||||
}
|
||||
for _, child := range n.Children {
|
||||
SortNode(child)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// StripTiming zeroes out Duration on every node in the tree so that test
|
||||
// assertions are not sensitive to wall-clock time.
|
||||
func StripTiming(n *Node) *Node {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
n.Duration = 0
|
||||
for _, child := range n.Children {
|
||||
StripTiming(child)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// CheckNode builds a NodeIsAllowed node.
|
||||
func CheckNode(t testing.TB, result NodeResult, tuple string, children ...*Node) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeIsAllowed, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, Children: children}
|
||||
}
|
||||
|
||||
// DirectNode builds a NodeDirect leaf (no children).
|
||||
func DirectNode(t testing.TB, result NodeResult, tuple string) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeDirect, Tuple: testhelpers.TupleFromString(t, tuple), Result: result}
|
||||
}
|
||||
|
||||
// ExpandNode builds a NodeExpandSubject node.
|
||||
func ExpandNode(t testing.TB, result NodeResult, tuple string, tuplesLoaded int, children ...*Node) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeExpandSubject, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, TuplesLoaded: tuplesLoaded, Children: children}
|
||||
}
|
||||
|
||||
// FoundNode builds a NodeFoundSubject leaf (no children).
|
||||
func FoundNode(t testing.TB, tuple string) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeFoundSubject, Tuple: testhelpers.TupleFromString(t, tuple), Result: ResultMember}
|
||||
}
|
||||
|
||||
// UnionNode builds a NodeUnion node.
|
||||
func UnionNode(t testing.TB, result NodeResult, tuple string, children ...*Node) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeUnion, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, Children: children}
|
||||
}
|
||||
|
||||
// IntersectionNode builds a NodeIntersection node.
|
||||
func IntersectionNode(t testing.TB, result NodeResult, tuple string, children ...*Node) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeIntersection, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, Children: children}
|
||||
}
|
||||
|
||||
// InvertNode builds a NodeInvert node.
|
||||
func InvertNode(t testing.TB, result NodeResult, tuple string, children ...*Node) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeInvert, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, Children: children}
|
||||
}
|
||||
|
||||
// ComputedNode builds a NodeComputed node.
|
||||
func ComputedNode(t testing.TB, result NodeResult, tuple string, children ...*Node) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeComputed, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, Children: children}
|
||||
}
|
||||
|
||||
// TraverseNode builds a NodeTraverse node.
|
||||
func TraverseNode(t testing.TB, result NodeResult, tuple string, tuplesLoaded int, children ...*Node) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeTraverse, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, TuplesLoaded: tuplesLoaded, Children: children}
|
||||
}
|
||||
|
||||
// MultiDirectNode builds a NodeMultiDirect leaf (no children).
|
||||
func MultiDirectNode(t testing.TB, result NodeResult, tuple string, relations []string) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeMultiDirect, Tuple: testhelpers.TupleFromString(t, tuple), Relations: relations, Result: result}
|
||||
}
|
||||
Reference in New Issue
Block a user