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

View File

@@ -0,0 +1,155 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package e2e
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
grpcHealthV1 "google.golang.org/grpc/health/grpc_health_v1"
"github.com/ory/herodot"
"github.com/ory/x/cmdx"
gprclient "github.com/ory/keto/cmd/client"
cliexpand "github.com/ory/keto/cmd/expand"
clirelationtuple "github.com/ory/keto/cmd/relationtuple"
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
"github.com/ory/keto/ketoapi"
)
type cliClient struct {
c *cmdx.CommandExecuter
}
func (g *cliClient) queryNamespaces(t testing.TB) (res ketoapi.GetNamespacesResponse) {
t.Skip("not implemented for the CLI")
return
}
var _ client = (*cliClient)(nil)
func (g *cliClient) oplCheckSyntax(t testing.TB, _ []byte) []*ketoapi.ParseError {
t.Skip("not implemented as a command yet")
return []*ketoapi.ParseError{}
}
func (g *cliClient) createTuple(t testing.TB, r *ketoapi.RelationTuple) {
tupleEnc, err := json.Marshal(r)
require.NoError(t, err)
stdout, stderr, err := g.c.Exec(bytes.NewBuffer(tupleEnc), "relation-tuple", "create", "-f", "-")
require.NoError(t, err, "stdout: %s\nstderr: %s", stdout, stderr)
assert.Len(t, stderr, 0, stdout)
}
func (g *cliClient) assembleQueryFlags(q *ketoapi.RelationQuery, opts []paginationOptionSetter) []string {
var flags []string
if q.Namespace != nil {
flags = append(flags, "--"+clirelationtuple.FlagNamespace, *q.Namespace)
}
if q.SubjectID != nil {
flags = append(flags, "--"+clirelationtuple.FlagSubjectID, *q.SubjectID)
}
if q.SubjectSet != nil {
flags = append(flags, "--"+clirelationtuple.FlagSubjectSet, q.SubjectSet.String())
}
if q.Relation != nil {
flags = append(flags, "--"+clirelationtuple.FlagRelation, *q.Relation)
}
if q.Object != nil {
flags = append(flags, "--"+clirelationtuple.FlagObject, *q.Object)
}
pagination := getPaginationOptions(opts...)
if pagination.Token != "" {
flags = append(flags, "--"+clirelationtuple.FlagPageToken, pagination.Token)
}
if pagination.Size != 0 {
flags = append(flags, "--"+clirelationtuple.FlagPageSize, strconv.Itoa(pagination.Size))
}
return flags
}
func (g *cliClient) queryTuple(t testing.TB, q *ketoapi.RelationQuery, opts ...paginationOptionSetter) *ketoapi.GetResponse {
out := g.c.ExecNoErr(t, append(g.assembleQueryFlags(q, opts), "relation-tuple", "get")...)
var resp ketoapi.GetResponse
require.NoError(t, json.Unmarshal([]byte(out), &resp), "%s", out)
return &resp
}
func (g *cliClient) queryTupleErr(t testing.TB, expected *herodot.DefaultError, q *ketoapi.RelationQuery, opts ...paginationOptionSetter) {
_, stdErr, err := g.c.Exec(nil, append(g.assembleQueryFlags(q, opts), "relation-tuple", "get")...)
assert.Error(t, err)
assert.Contains(t, stdErr, expected.GRPCCodeField.String())
assert.Contains(t, stdErr, expected.Error())
}
func (g *cliClient) check(t testing.TB, r *ketoapi.RelationTuple) bool {
var sub string
if r.SubjectID != nil {
sub = *r.SubjectID
} else {
sub = r.SubjectSet.String()
}
out := g.c.ExecNoErr(t, "check", sub, r.Relation, r.Namespace+":"+r.Object)
var res rts.CheckResponse
require.NoError(t, json.Unmarshal([]byte(out), &res))
return res.Allowed
}
func (g *cliClient) batchCheckErr(t testing.TB, _ []*ketoapi.RelationTuple, expected *herodot.DefaultError) {
t.Skip("not implemented for the CLI")
}
func (g *cliClient) batchCheck(t testing.TB, r []*ketoapi.RelationTuple) []checkResponse {
t.Skip("not implemented for the CLI")
return nil
}
func (g *cliClient) expand(t testing.TB, r *ketoapi.SubjectSet, depth int) *ketoapi.Tree[*ketoapi.RelationTuple] {
out := g.c.ExecNoErr(t, "expand", r.Relation, r.Namespace+":"+r.Object, "--"+cliexpand.FlagMaxDepth, fmt.Sprintf("%d", depth), "--"+cmdx.FlagFormat, string(cmdx.FormatJSON))
res := ketoapi.Tree[*ketoapi.RelationTuple]{}
require.NoError(t, json.Unmarshal([]byte(out), &res))
return &res
}
func (g *cliClient) waitUntilLive(t testing.TB) {
flags := make([]string, len(g.c.PersistentArgs))
copy(flags, g.c.PersistentArgs)
for i, f := range flags {
if f == "--"+cmdx.FlagFormat {
flags = append(flags[:i], flags[i+2:]...)
break
}
}
ctx, cancel := context.WithTimeout(g.c.Ctx, time.Minute)
defer cancel()
out := cmdx.ExecNoErrCtx(ctx, t, g.c.New(), append(flags, "status", "--"+gprclient.FlagBlock)...)
require.Equal(t, grpcHealthV1.HealthCheckResponse_SERVING.String()+"\n", out)
}
func (g *cliClient) deleteTuple(t testing.TB, r *ketoapi.RelationTuple) {
tupleEnc, err := json.Marshal(r)
require.NoError(t, err)
stdout, stderr, err := g.c.Exec(bytes.NewBuffer(tupleEnc), "relation-tuple", "delete", "-f", "-")
require.NoError(t, err, "stdout: %s\nstderr: %s", stdout, stderr)
assert.Len(t, stderr, 0, stderr)
}
func (g *cliClient) deleteAllTuples(t testing.TB, q *ketoapi.RelationQuery) {
_ = g.c.ExecNoErr(t, append(g.assembleQueryFlags(q, nil), "relation-tuple", "delete-all", "--force")...)
}

View File

@@ -0,0 +1,160 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package e2e
import (
"fmt"
"io"
"net/http"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/herodot"
"github.com/ory/x/cmdx"
prometheus "github.com/ory/x/prometheusx"
"github.com/ory/keto/cmd"
cliclient "github.com/ory/keto/cmd/client"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/internal/x/dbx"
"github.com/ory/keto/ketoapi"
)
type (
transactClient interface {
client
transactTuples(t testing.TB, ins []*ketoapi.RelationTuple, del []*ketoapi.RelationTuple)
}
client interface {
createTuple(t testing.TB, r *ketoapi.RelationTuple)
deleteTuple(t testing.TB, r *ketoapi.RelationTuple)
deleteAllTuples(t testing.TB, q *ketoapi.RelationQuery)
queryTuple(t testing.TB, q *ketoapi.RelationQuery, opts ...paginationOptionSetter) *ketoapi.GetResponse
queryTupleErr(t testing.TB, expected *herodot.DefaultError, q *ketoapi.RelationQuery, opts ...paginationOptionSetter)
check(t testing.TB, r *ketoapi.RelationTuple) bool
batchCheck(t testing.TB, r []*ketoapi.RelationTuple) []checkResponse
batchCheckErr(t testing.TB, requestTuples []*ketoapi.RelationTuple, expected *herodot.DefaultError)
expand(t testing.TB, r *ketoapi.SubjectSet, depth int) *ketoapi.Tree[*ketoapi.RelationTuple]
oplCheckSyntax(t testing.TB, content []byte) []*ketoapi.ParseError
waitUntilLive(t testing.TB)
queryNamespaces(t testing.TB) ketoapi.GetNamespacesResponse
}
)
const (
promLogLine = "promhttp_metric_handler_requests_total"
)
func Test(t *testing.T) {
t.Parallel()
for _, dsn := range dbx.GetDSNs(t) {
t.Run(fmt.Sprintf("dsn=%s", dsn.Name), func(t *testing.T) {
t.Parallel()
ctx, reg, namespaceTestMgr, getAddr := newInitializedReg(t, dsn, nil)
closeServer := startServer(ctx, t, reg)
t.Cleanup(closeServer)
_, _, readAddr := getAddr(t, "read")
_, _, writeAddr := getAddr(t, "write")
_, _, oplAddr := getAddr(t, "opl")
_, _, metricsAddr := getAddr(t, "metrics")
// The test cases start here
// We execute every test with all clients available
for _, cl := range []client{
newGrpcClient(t, ctx,
readAddr,
writeAddr,
oplAddr,
),
&restClient{
readURL: "http://" + readAddr,
writeURL: "http://" + writeAddr,
oplSyntaxURL: "http://" + oplAddr,
},
&cliClient{c: &cmdx.CommandExecuter{
New: func() *cobra.Command {
return cmd.NewRootCmd(nil)
},
Ctx: ctx,
PersistentArgs: []string{
"--" + cliclient.FlagReadRemote, readAddr,
"--" + cliclient.FlagWriteRemote, writeAddr,
"--insecure-disable-transport-security=true",
"--" + cmdx.FlagFormat, string(cmdx.FormatJSON),
},
}},
&sdkClient{
readRemote: readAddr,
writeRemote: writeAddr,
syntaxRemote: oplAddr,
},
} {
t.Run(fmt.Sprintf("client=%T", cl), runCases(cl, namespaceTestMgr))
if tc, ok := cl.(transactClient); ok {
t.Run(fmt.Sprintf("transactClient=%T", cl), runTransactionCases(tc, namespaceTestMgr))
}
}
t.Run("case=metrics are served", func(t *testing.T) {
t.Parallel()
newGrpcClient(t, ctx,
readAddr,
writeAddr,
oplAddr,
).waitUntilLive(t)
t.Run("case=on "+prometheus.MetricsPrometheusPath, func(t *testing.T) {
t.Parallel()
resp, err := http.Get(fmt.Sprintf("http://%s%s", metricsAddr, prometheus.MetricsPrometheusPath))
require.NoError(t, err)
require.Equal(t, resp.StatusCode, http.StatusOK)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Contains(t, string(body), promLogLine)
})
t.Run("case=not on /", func(t *testing.T) {
t.Parallel()
resp, err := http.Get(fmt.Sprintf("http://%s", metricsAddr))
require.NoError(t, err)
require.Equal(t, resp.StatusCode, http.StatusNotFound)
})
})
})
}
}
func TestServeCORS(t *testing.T) {
t.Parallel()
ctx, reg, _, getAddr := newInitializedReg(t, dbx.GetSqlite(t), map[string]any{
"serve.read.cors.enabled": true,
"serve.read.cors.debug": true,
"serve.read.cors.allowed_methods": []string{http.MethodGet},
"serve.read.cors.allowed_origins": []string{"https://ory.sh"},
})
closeServer := startServer(ctx, t, reg)
t.Cleanup(closeServer)
_, _, readAddr := getAddr(t, "read")
waitUntilLive(t, "http://"+readAddr)
t.Log("Health check is ready")
req, err := http.NewRequest(http.MethodOptions, "http://"+readAddr+relationtuple.ReadRouteBase, nil)
require.NoError(t, err)
req.Header.Set("Origin", "https://ory.sh")
req.Header.Set("Access-Control-Request-Method", "GET")
resp, err := http.DefaultClient.Do(req) //nolint:gosec // Test code with controlled URL.
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, resp.StatusCode)
assert.Equalf(t, "https://ory.sh", resp.Header.Get("Access-Control-Allow-Origin"), "%+v", resp.Header)
}

View File

@@ -0,0 +1,285 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package e2e
import (
"context"
"encoding/json"
"math"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
grpcHealthV1 "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/status"
"github.com/ory/herodot"
opl "github.com/ory/keto/gen/go/ory/keto/opl/v1alpha1"
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
"github.com/ory/keto/ketoapi"
)
type grpcClient struct {
read, write, oplSyntax *grpc.ClientConn
ctx context.Context
}
func newGrpcClient(t testing.TB, ctx context.Context, readRemote, writeRemote, oplSyntaxRemote string) *grpcClient {
read, err := grpc.NewClient(readRemote, grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, read.Close()) })
write, err := grpc.NewClient(writeRemote, grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, write.Close()) })
oplSyntax, err := grpc.NewClient(oplSyntaxRemote, grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, oplSyntax.Close()) })
ctx, cancel := context.WithCancel(ctx)
t.Cleanup(cancel)
return &grpcClient{
read: read,
write: write,
oplSyntax: oplSyntax,
ctx: ctx,
}
}
func (g *grpcClient) queryNamespaces(t testing.TB) (apiResponse ketoapi.GetNamespacesResponse) {
client := rts.NewNamespacesServiceClient(g.read)
res, err := client.ListNamespaces(g.ctx, &rts.ListNamespacesRequest{})
require.NoError(t, err)
require.NoError(t, convert(res, &apiResponse))
return
}
var _ transactClient = (*grpcClient)(nil)
func (g *grpcClient) createTuple(t testing.TB, r *ketoapi.RelationTuple) {
g.transactTuples(t, []*ketoapi.RelationTuple{r}, nil)
}
func (*grpcClient) createQuery(q *ketoapi.RelationQuery) *rts.RelationQuery {
query := &rts.RelationQuery{
Namespace: q.Namespace,
Object: q.Object,
Relation: q.Relation,
}
if q.SubjectID != nil {
query.Subject = rts.NewSubjectID(*q.SubjectID)
} else if q.SubjectSet != nil {
query.Subject = rts.NewSubjectSet(q.SubjectSet.Namespace, q.SubjectSet.Object, q.SubjectSet.Relation)
}
return query
}
func requireInt32WithinBounds(t testing.TB, v int) int32 {
require.GreaterOrEqual(t, v, math.MinInt32)
require.LessOrEqual(t, v, math.MaxInt32)
return int32(v) // #nosec G115 -- value validated above
}
func (g *grpcClient) queryTuple(t testing.TB, q *ketoapi.RelationQuery, opts ...paginationOptionSetter) *ketoapi.GetResponse {
c := rts.NewReadServiceClient(g.read)
pagination := getPaginationOptions(opts...)
resp, err := c.ListRelationTuples(g.ctx, &rts.ListRelationTuplesRequest{
RelationQuery: g.createQuery(q),
PageToken: pagination.Token,
PageSize: requireInt32WithinBounds(t, pagination.Size),
})
require.NoError(t, err)
irs := make([]*ketoapi.RelationTuple, len(resp.RelationTuples))
for i := range irs {
irs[i], err = (&ketoapi.RelationTuple{}).FromDataProvider(resp.RelationTuples[i])
require.NoError(t, err)
}
return &ketoapi.GetResponse{
RelationTuples: irs,
NextPageToken: resp.NextPageToken,
}
}
func (g *grpcClient) queryTupleErr(t testing.TB, expected *herodot.DefaultError, q *ketoapi.RelationQuery, opts ...paginationOptionSetter) {
c := rts.NewReadServiceClient(g.read)
pagination := getPaginationOptions(opts...)
_, err := c.ListRelationTuples(g.ctx, &rts.ListRelationTuplesRequest{
RelationQuery: g.createQuery(q),
PageToken: pagination.Token,
PageSize: requireInt32WithinBounds(t, pagination.Size),
})
require.Error(t, err)
s, ok := status.FromError(err)
require.True(t, ok)
assert.Equal(t, expected.GRPCCodeField, s.Code(), "%+v", err)
}
func (g *grpcClient) check(t testing.TB, r *ketoapi.RelationTuple) bool {
c := rts.NewCheckServiceClient(g.read)
req := &rts.CheckRequest{
Tuple: &rts.RelationTuple{
Namespace: r.Namespace,
Object: r.Object,
Relation: r.Relation,
},
}
if r.SubjectID != nil {
req.Tuple.Subject = rts.NewSubjectID(*r.SubjectID)
} else {
req.Tuple.Subject = rts.NewSubjectSet(r.SubjectSet.Namespace, r.SubjectSet.Object, r.SubjectSet.Relation)
}
resp, err := c.Check(g.ctx, req)
require.NoError(t, err)
return resp.Allowed
}
type checkResponse struct {
allowed bool
errorMessage string
}
func (g *grpcClient) batchCheckErr(t testing.TB, requestTuples []*ketoapi.RelationTuple, expected *herodot.DefaultError) {
_, err := g.doBatchCheck(t, requestTuples)
require.Error(t, err)
s, ok := status.FromError(err)
require.True(t, ok)
assert.Equalf(t, expected.GRPCCodeField, s.Code(), "%+v", err)
assert.Contains(t, s.Message(), expected.Reason())
}
func (g *grpcClient) batchCheck(t testing.TB, requestTuples []*ketoapi.RelationTuple) []checkResponse {
resp, err := g.doBatchCheck(t, requestTuples)
require.NoError(t, err)
checkResponses := make([]checkResponse, len(resp.Results))
for i, r := range resp.Results {
checkResponses[i] = checkResponse{
allowed: r.Allowed,
errorMessage: r.Error,
}
}
return checkResponses
}
func (g *grpcClient) doBatchCheck(_ testing.TB, requestTuples []*ketoapi.RelationTuple) (*rts.BatchCheckResponse, error) {
c := rts.NewCheckServiceClient(g.read)
tuples := make([]*rts.RelationTuple, len(requestTuples))
for i, tuple := range requestTuples {
var subject *rts.Subject
if tuple.SubjectID != nil {
subject = rts.NewSubjectID(*tuple.SubjectID)
} else {
subject = rts.NewSubjectSet(tuple.SubjectSet.Namespace, tuple.SubjectSet.Object, tuple.SubjectSet.Relation)
}
tuples[i] = &rts.RelationTuple{
Namespace: tuple.Namespace,
Object: tuple.Object,
Relation: tuple.Relation,
Subject: subject,
}
}
req := &rts.BatchCheckRequest{
Tuples: tuples,
}
return c.BatchCheck(g.ctx, req)
}
func (g *grpcClient) expand(t testing.TB, r *ketoapi.SubjectSet, depth int) *ketoapi.Tree[*ketoapi.RelationTuple] {
c := rts.NewExpandServiceClient(g.read)
resp, err := c.Expand(g.ctx, &rts.ExpandRequest{
Subject: rts.NewSubjectSet(r.Namespace, r.Object, r.Relation),
MaxDepth: requireInt32WithinBounds(t, depth),
})
require.NoError(t, err)
return ketoapi.TreeFromProto[*ketoapi.RelationTuple](resp.Tree)
}
func (g *grpcClient) waitUntilLive(t testing.TB) {
require.EventuallyWithT(t, func(t *assert.CollectT) {
c := grpcHealthV1.NewHealthClient(g.read)
res, err := c.Check(g.ctx, &grpcHealthV1.HealthCheckRequest{})
require.NoError(t, err)
assert.Equal(t, grpcHealthV1.HealthCheckResponse_SERVING, res.Status)
}, 2*time.Second, 10*time.Millisecond)
}
func (g *grpcClient) deleteTuple(t testing.TB, r *ketoapi.RelationTuple) {
g.transactTuples(t, nil, []*ketoapi.RelationTuple{r})
}
func (g *grpcClient) deleteAllTuples(t testing.TB, q *ketoapi.RelationQuery) {
c := rts.NewWriteServiceClient(g.write)
query := &rts.RelationQuery{
Namespace: q.Namespace,
Object: q.Object,
Relation: q.Relation,
}
if q.SubjectID != nil {
query.Subject = rts.NewSubjectID(*q.SubjectID)
}
if q.SubjectSet != nil {
query.Subject = rts.NewSubjectSet(q.SubjectSet.Namespace, q.SubjectSet.Object, q.SubjectSet.Relation)
}
_, err := c.DeleteRelationTuples(g.ctx, &rts.DeleteRelationTuplesRequest{
RelationQuery: query,
})
require.NoError(t, err)
}
func (g *grpcClient) transactTuples(t testing.TB, ins []*ketoapi.RelationTuple, del []*ketoapi.RelationTuple) {
c := rts.NewWriteServiceClient(g.write)
deltas := make([]*rts.RelationTupleDelta, len(ins)+len(del))
for i := range ins {
deltas[i] = &rts.RelationTupleDelta{
RelationTuple: ins[i].ToProto(),
Action: rts.RelationTupleDelta_ACTION_INSERT,
}
}
for i := range del {
deltas[len(ins)+i] = &rts.RelationTupleDelta{
RelationTuple: del[i].ToProto(),
Action: rts.RelationTupleDelta_ACTION_DELETE,
}
}
_, err := c.TransactRelationTuples(g.ctx, &rts.TransactRelationTuplesRequest{
RelationTupleDeltas: deltas,
})
require.NoError(t, err)
}
func (g *grpcClient) oplCheckSyntax(t testing.TB, content []byte) (parseErrors []*ketoapi.ParseError) {
c := opl.NewSyntaxServiceClient(g.oplSyntax)
res, err := c.Check(g.ctx, &opl.CheckRequest{Content: content})
require.NoError(t, err)
raw, err := json.Marshal(res.ParseErrors)
require.NoError(t, err)
err = json.Unmarshal(raw, &parseErrors)
require.NoError(t, err)
return
}

164
internal/e2e/helpers.go Normal file
View File

@@ -0,0 +1,164 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package e2e
import (
"context"
"encoding/json"
"sync"
"testing"
"github.com/ory/x/configx"
"github.com/spf13/pflag"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/keto/internal/driver"
"github.com/ory/keto/internal/driver/config"
"github.com/ory/keto/internal/namespace"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/internal/x/dbx"
)
type namespaceTestManager struct {
sync.Mutex
reg driver.Registry
ctx context.Context
nspaces []*namespace.Namespace
}
func (m *namespaceTestManager) add(t testing.TB, nn ...*namespace.Namespace) {
m.Lock()
defer m.Unlock()
m.nspaces = append(m.nspaces, nn...)
require.NoError(t, m.reg.Config(m.ctx).Set(config.KeyNamespaces, m.nspaces))
t.Cleanup(func() {
for _, n := range nn {
require.NoError(t, m.reg.RelationTupleManager().DeleteAllRelationTuples(m.ctx, &relationtuple.RelationQuery{
Namespace: &n.Name,
}))
}
})
}
func (m *namespaceTestManager) remove(t *testing.T, name string) {
newNamespaces := make([]*namespace.Namespace, 0, len(m.nspaces))
for _, n := range m.nspaces {
if n.Name != name {
newNamespaces = append(newNamespaces, n)
}
}
m.nspaces = newNamespaces
require.NoError(t, m.reg.Config(m.ctx).Set(config.KeyNamespaces, m.nspaces))
}
func newInitializedReg(t testing.TB, dsn *dbx.DsnT, cfgOverwrites map[string]interface{}) (context.Context, driver.Registry, *namespaceTestManager, driver.GetAddr) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(func() {
cancel()
})
flags := pflag.NewFlagSet("", pflag.ContinueOnError)
configx.RegisterConfigFlag(flags, nil)
cfgValues := map[string]interface{}{
config.KeyDSN: dsn.Conn,
"log.level": "debug",
"log.leak_sensitive_values": true,
config.KeyReadAPIHost: "127.0.0.1",
config.KeyWriteAPIHost: "127.0.0.1",
config.KeyOPLSyntaxAPIHost: "127.0.0.1",
config.KeyMetricsHost: "127.0.0.1",
config.KeyNamespaces: []*namespace.Namespace{},
config.KeySecretsPagination: []string{"test pagination secret"},
}
for k, v := range cfgOverwrites {
cfgValues[k] = v
}
cf := dbx.ConfigFile(t, cfgValues)
require.NoError(t, flags.Parse([]string{"--" + configx.FlagConfig, cf}))
reg, err := driver.NewDefaultRegistry(ctx, flags, true, nil)
require.NoError(t, err)
getAddr := driver.UseDynamicPorts(t, reg)
require.NoError(t, reg.MigrateUp(ctx))
assertMigrated(ctx, t, reg)
return ctx, reg, &namespaceTestManager{
reg: reg,
ctx: ctx,
nspaces: []*namespace.Namespace{},
}, getAddr
}
func assertMigrated(ctx context.Context, t testing.TB, r driver.Registry) {
mb, err := r.MigrationBox(ctx)
require.NoError(t, err)
s, err := mb.Status(ctx)
require.NoError(t, err)
assert.False(t, s.HasPending())
}
func startServer(ctx context.Context, t testing.TB, reg driver.Registry) func() {
// Start the server
serverCtx, serverCancel := context.WithCancel(ctx)
serverErr := make(chan error)
go func() {
serverErr <- reg.ServeAll(serverCtx)
}()
// defer this close function to make sure it is shutdown on test failure as well
return func() {
// stop the server
serverCancel()
// wait for it to stop
require.NoError(t, <-serverErr)
}
}
// convert the struct in `from` to the pointer in `to` using JSON marshal and unmarshal. from and toPtr must have the
// same json field tags.
func convert(from, toPtr any) error {
raw, err := json.Marshal(from)
if err != nil {
return err
}
return json.Unmarshal(raw, toPtr)
}
type (
paginationOptions struct {
Token string `json:"page_token"`
Size int `json:"page_size"`
}
paginationOptionSetter func(*paginationOptions) *paginationOptions
)
func withToken(t string) paginationOptionSetter {
return func(opts *paginationOptions) *paginationOptions {
opts.Token = t
return opts
}
}
func withSize(size int) paginationOptionSetter {
return func(opts *paginationOptions) *paginationOptions {
opts.Size = size
return opts
}
}
func getPaginationOptions(modifiers ...paginationOptionSetter) *paginationOptions {
opts := &paginationOptions{}
for _, f := range modifiers {
opts = f(opts)
}
return opts
}

View File

@@ -0,0 +1,222 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package e2e
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/herodot"
"github.com/ory/x/healthx"
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
"github.com/ory/keto/internal/check"
"github.com/ory/keto/internal/expand"
httpclient "github.com/ory/keto/internal/httpclient"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/ketoapi"
"github.com/ory/keto/schema"
)
var _ client = &restClient{}
type restClient struct {
readURL, writeURL, oplSyntaxURL string
}
func (rc *restClient) queryNamespaces(t testing.TB) (res ketoapi.GetNamespacesResponse) {
body, code := rc.makeRequest(t, http.MethodGet, "/namespaces", "", rc.readURL)
assert.Equal(t, http.StatusOK, code, body)
require.NoError(t, json.Unmarshal([]byte(body), &res))
return
}
func (rc *restClient) oplCheckSyntax(t testing.TB, content []byte) []*ketoapi.ParseError {
body, code := rc.makeRequest(t, http.MethodPost, schema.RouteBase, string(content), rc.oplSyntaxURL)
assert.Equal(t, http.StatusOK, code, body)
var response ketoapi.CheckOPLSyntaxResponse
require.NoError(t, json.Unmarshal([]byte(body), &response))
return response.Errors
}
func (rc *restClient) makeRequest(t testing.TB, method, path, body string, baseURL string) (string, int) {
var b io.Reader
if body != "" {
b = bytes.NewBufferString(body)
}
// t.Logf("Requesting %s %s%s with body %#v", method, baseURL, path, body)
req, err := http.NewRequest(method, baseURL+path, b)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req) //nolint:gosec // Test code with controlled URL.
require.NoError(t, err)
respBody, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
return string(respBody), resp.StatusCode
}
func (rc *restClient) createTuple(t testing.TB, r *ketoapi.RelationTuple) {
tEnc, err := json.Marshal(r)
require.NoError(t, err)
body, code := rc.makeRequest(t, http.MethodPut, relationtuple.WriteRouteBase, string(tEnc), rc.writeURL)
assert.Equal(t, http.StatusCreated, code, body)
}
func (rc *restClient) deleteTuple(t testing.TB, r *ketoapi.RelationTuple) {
body, code := rc.makeRequest(t, http.MethodDelete, relationtuple.WriteRouteBase+"?"+r.ToURLQuery().Encode(), "", rc.writeURL)
require.Equal(t, http.StatusNoContent, code, body)
}
func (rc *restClient) deleteAllTuples(t testing.TB, q *ketoapi.RelationQuery) {
body, code := rc.makeRequest(t, http.MethodDelete, relationtuple.WriteRouteBase+"?"+q.ToURLQuery().Encode(), "", rc.writeURL)
require.Equal(t, http.StatusNoContent, code, body)
}
func (rc *restClient) queryTuple(t testing.TB, q *ketoapi.RelationQuery, opts ...paginationOptionSetter) *ketoapi.GetResponse {
urlQuery := q.ToURLQuery()
pagination := getPaginationOptions(opts...)
if pagination.Size != 0 {
urlQuery.Set("page_size", strconv.Itoa(pagination.Size))
}
if pagination.Token != "" {
urlQuery.Set("page_token", pagination.Token)
}
body, code := rc.makeRequest(t, http.MethodGet, fmt.Sprintf("%s?%s", relationtuple.ReadRouteBase, urlQuery.Encode()), "", rc.readURL)
require.Equal(t, http.StatusOK, code, body)
var dec ketoapi.GetResponse
require.NoError(t, json.Unmarshal([]byte(body), &dec))
return &dec
}
func (rc *restClient) queryTupleErr(t testing.TB, expected *herodot.DefaultError, q *ketoapi.RelationQuery, opts ...paginationOptionSetter) {
urlQuery := q.ToURLQuery()
pagination := getPaginationOptions(opts...)
if pagination.Size != 0 {
urlQuery.Set("page_size", strconv.Itoa(pagination.Size))
}
if pagination.Token != "" {
urlQuery.Set("page_token", pagination.Token)
}
body, code := rc.makeRequest(t, http.MethodGet, fmt.Sprintf("%s?%s", relationtuple.ReadRouteBase, urlQuery.Encode()), "", rc.readURL)
assert.Equal(t, expected.CodeField, code)
assert.Equal(t, int64(expected.StatusCode()), gjson.Get(body, "error.code").Int())
assert.Equal(t, expected.Status(), gjson.Get(body, "error.status").String())
assert.Equal(t, expected.Error(), gjson.Get(body, "error.message").String(), body)
}
func (rc *restClient) check(t testing.TB, r *ketoapi.RelationTuple) bool {
q := r.ToURLQuery()
bodyGet, codeGet := rc.makeRequest(t, http.MethodGet, fmt.Sprintf("%s?%s", check.RouteBase, q.Encode()), "", rc.readURL)
var respGet rts.CheckResponse
require.NoError(t, json.Unmarshal([]byte(bodyGet), &respGet))
j, err := json.Marshal(r)
require.NoError(t, err)
bodyPost, codePost := rc.makeRequest(t, http.MethodPost, check.RouteBase, string(j), rc.readURL)
var respPost rts.CheckResponse
require.NoError(t, json.Unmarshal([]byte(bodyPost), &respPost))
if codeGet == http.StatusOK && codePost == http.StatusOK {
assert.Equal(t, true, respGet.Allowed, "%s", bodyGet)
assert.Equal(t, true, respPost.Allowed, "%s", bodyPost)
return true
}
assert.Equal(t, http.StatusForbidden, codeGet, bodyGet)
assert.Equal(t, http.StatusForbidden, codePost, bodyPost)
assert.Equal(t, false, respGet.Allowed)
assert.Equal(t, false, respPost.Allowed)
return false
}
func (rc *restClient) batchCheckErr(t testing.TB, requestTuples []*ketoapi.RelationTuple, expected *herodot.DefaultError) {
req := httpclient.BatchCheckPermissionBody{
Tuples: tuplesToRelationships(requestTuples),
}
j, err := json.Marshal(req)
require.NoError(t, err)
body, code := rc.makeRequest(t, http.MethodPost, check.BatchRoute, string(j), rc.readURL)
assert.Equal(t, expected.CodeField, code)
assert.Contains(t, body, expected.Reason())
}
func (rc *restClient) batchCheck(t testing.TB, requestTuples []*ketoapi.RelationTuple) []checkResponse {
req := httpclient.BatchCheckPermissionBody{
Tuples: tuplesToRelationships(requestTuples),
}
j, err := json.Marshal(req)
require.NoError(t, err)
body, code := rc.makeRequest(t, http.MethodPost, check.BatchRoute, string(j), rc.readURL)
require.Equal(t, http.StatusOK, code, "batch check failed unexpected with error code %d", code)
var respPost rts.BatchCheckResponse
require.NoError(t, json.Unmarshal([]byte(body), &respPost))
responseChecks := make([]checkResponse, len(respPost.Results))
for i, result := range respPost.Results {
responseChecks[i] = checkResponse{
allowed: result.Allowed,
errorMessage: result.Error,
}
}
return responseChecks
}
func (rc *restClient) expand(t testing.TB, r *ketoapi.SubjectSet, depth int) *ketoapi.Tree[*ketoapi.RelationTuple] {
query := r.ToURLQuery()
query.Set("max-depth", fmt.Sprintf("%d", depth))
body, code := rc.makeRequest(t, http.MethodGet, fmt.Sprintf("%s?%s", expand.RouteBase, query.Encode()), "", rc.readURL)
require.Equal(t, http.StatusOK, code, body)
tree := &ketoapi.Tree[*ketoapi.RelationTuple]{}
require.NoError(t, json.Unmarshal([]byte(body), tree))
return tree
}
func healthReady(t require.TestingT, readURL string) int {
req, err := http.NewRequest("GET", readURL+healthx.ReadyCheckPath, nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req) //nolint:gosec // Test code with controlled URL.
if err != nil {
return 0
}
defer func() { _ = resp.Body.Close() }()
return resp.StatusCode
}
func waitUntilLive(t testing.TB, readURL string) {
require.EventuallyWithT(t, func(t *assert.CollectT) {
assert.Equal(t, http.StatusOK, healthReady(t, readURL))
}, 5*time.Second, 10*time.Millisecond)
}
func (rc *restClient) waitUntilLive(t testing.TB) {
waitUntilLive(t, rc.readURL)
}

View File

@@ -0,0 +1,360 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package e2e
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/herodot"
httpclient "github.com/ory/keto/internal/httpclient"
"github.com/ory/keto/ketoapi"
)
type (
sdkClient struct {
rc, wc, sc *httpclient.APIClient
readRemote, writeRemote, syntaxRemote string
}
)
var _ client = (*sdkClient)(nil)
var requestTimeout = 5 * time.Second
func (c *sdkClient) requestCtx(t testing.TB) context.Context {
ctx, cancel := context.WithTimeout(context.Background(), requestTimeout)
t.Cleanup(cancel)
return ctx
}
func (c *sdkClient) oplCheckSyntax(t testing.TB, content []byte) (parseErrors []*ketoapi.ParseError) {
res, _, err := c.getOPLSyntaxClient().
RelationshipApi.
CheckOplSyntax(c.requestCtx(t)).
Body(string(content)).
Execute()
require.NoError(t, err)
raw, err := json.Marshal(res.Errors)
require.NoError(t, err)
err = json.Unmarshal(raw, &parseErrors)
require.NoError(t, err)
return
}
func (c *sdkClient) getReadClient() *httpclient.APIClient {
if c.rc == nil {
cfg := httpclient.NewConfiguration()
cfg.Host = c.readRemote
cfg.Scheme = "http"
c.rc = httpclient.NewAPIClient(cfg)
}
return c.rc
}
func (c *sdkClient) getWriteClient() *httpclient.APIClient {
if c.wc == nil {
cfg := httpclient.NewConfiguration()
cfg.Host = c.writeRemote
cfg.Scheme = "http"
c.wc = httpclient.NewAPIClient(cfg)
}
return c.wc
}
func (c *sdkClient) getOPLSyntaxClient() *httpclient.APIClient {
if c.sc == nil {
cfg := httpclient.NewConfiguration()
cfg.Host = c.syntaxRemote
cfg.Scheme = "http"
c.sc = httpclient.NewAPIClient(cfg)
}
return c.sc
}
func (c *sdkClient) createTuple(t testing.TB, r *ketoapi.RelationTuple) {
payload := httpclient.CreateRelationshipBody{
Namespace: new(r.Namespace),
Object: new(r.Object),
Relation: new(r.Relation),
SubjectId: r.SubjectID,
}
if r.SubjectID == nil {
payload.SubjectSet = &httpclient.SubjectSet{
Namespace: r.SubjectSet.Namespace,
Object: r.SubjectSet.Object,
Relation: r.SubjectSet.Relation,
}
}
_, _, err := c.getWriteClient().RelationshipApi.
CreateRelationship(c.requestCtx(t)).
CreateRelationshipBody(payload).
Execute()
require.NoError(t, err)
}
func withSubject[P interface {
SubjectId(string) P
SubjectSetNamespace(string) P
SubjectSetObject(string) P
SubjectSetRelation(string) P
}](params P, subID *string, subSet *ketoapi.SubjectSet) P {
if subID != nil {
return params.SubjectId(*subID)
}
if subSet != nil {
return params.
SubjectSetNamespace(subSet.Namespace).
SubjectSetObject(subSet.Object).
SubjectSetRelation(subSet.Relation)
}
return params
}
func (c *sdkClient) deleteTuple(t testing.TB, r *ketoapi.RelationTuple) {
request := c.getWriteClient().RelationshipApi.
DeleteRelationships(c.requestCtx(t)).
Namespace(r.Namespace).
Object(r.Object).
Relation(r.Relation)
request = withSubject(request, r.SubjectID, r.SubjectSet)
_, err := request.Execute()
require.NoError(t, err)
}
func (c *sdkClient) deleteAllTuples(t testing.TB, q *ketoapi.RelationQuery) {
request := c.getWriteClient().RelationshipApi.DeleteRelationships(c.requestCtx(t))
if q.Namespace != nil {
request = request.Namespace(*q.Namespace)
}
if q.Object != nil {
request = request.Object(*q.Object)
}
if q.Relation != nil {
request = request.Relation(*q.Relation)
}
request = withSubject(request, q.SubjectID, q.SubjectSet)
_, err := request.Execute()
require.NoError(t, err)
}
func compileParams(req httpclient.RelationshipApiApiGetRelationshipsRequest, q *ketoapi.RelationQuery, opts []paginationOptionSetter) httpclient.RelationshipApiApiGetRelationshipsRequest {
if q.Namespace != nil {
req = req.Namespace(*q.Namespace)
}
if q.Relation != nil {
req = req.Relation(*q.Relation)
}
if q.Object != nil {
req = req.Object(*q.Object)
}
if q.SubjectID != nil {
req = req.SubjectId(*q.SubjectID)
}
if q.SubjectSet != nil {
req = req.
SubjectSetNamespace(q.SubjectSet.Namespace).
SubjectSetObject(q.SubjectSet.Object).
SubjectSetRelation(q.SubjectSet.Relation)
}
pagination := getPaginationOptions(opts...)
if pagination.Size != 0 {
req = req.PageSize(int64(pagination.Size))
}
if pagination.Token != "" {
req = req.PageToken(pagination.Token)
}
return req
}
func (c *sdkClient) queryTuple(t testing.TB, q *ketoapi.RelationQuery, opts ...paginationOptionSetter) *ketoapi.GetResponse {
request := c.getReadClient().RelationshipApi.GetRelationships(c.requestCtx(t))
request = compileParams(request, q, opts)
resp, _, err := request.Execute()
require.NoError(t, err)
getResp := &ketoapi.GetResponse{
RelationTuples: make([]*ketoapi.RelationTuple, len(resp.RelationTuples)),
NextPageToken: resp.GetNextPageToken(),
}
for i, rt := range resp.RelationTuples {
getResp.RelationTuples[i] = &ketoapi.RelationTuple{
Namespace: rt.Namespace,
Object: rt.Object,
Relation: rt.Relation,
}
if rt.SubjectSet != nil {
getResp.RelationTuples[i].SubjectSet = &ketoapi.SubjectSet{
Namespace: rt.SubjectSet.Namespace,
Object: rt.SubjectSet.Object,
Relation: rt.SubjectSet.Relation,
}
} else {
getResp.RelationTuples[i].SubjectID = rt.SubjectId
}
}
return getResp
}
func (c *sdkClient) queryTupleErr(t testing.TB, expected *herodot.DefaultError, q *ketoapi.RelationQuery, opts ...paginationOptionSetter) {
request := c.getReadClient().RelationshipApi.GetRelationships(c.requestCtx(t))
request = compileParams(request, q, opts)
_, _, err := request.Execute()
switch err.(type) {
case nil:
require.FailNow(t, "expected error but got nil")
case *httpclient.GenericOpenAPIError:
assert.Equal(t, expected.CodeField, http.StatusNotFound)
default:
require.FailNow(t, "got unknown error %+v\nexpected %+v", err, expected)
}
}
func (c *sdkClient) check(t testing.TB, r *ketoapi.RelationTuple) bool {
request := c.getReadClient().PermissionApi.CheckPermission(c.requestCtx(t)).
Namespace(r.Namespace).
Object(r.Object).
Relation(r.Relation)
request = withSubject(request, r.SubjectID, r.SubjectSet)
resp, _, err := request.Execute()
require.NoError(t, err)
return resp.GetAllowed()
}
func (c *sdkClient) batchCheckErr(t testing.TB, requestTuples []*ketoapi.RelationTuple, expected *herodot.DefaultError) {
request := c.getReadClient().PermissionApi.BatchCheckPermission(c.requestCtx(t)).
BatchCheckPermissionBody(httpclient.BatchCheckPermissionBody{
Tuples: tuplesToRelationships(requestTuples),
})
_, _, err := request.Execute()
switch typedErr := err.(type) {
case nil:
require.FailNow(t, "expected error but got nil")
case *httpclient.GenericOpenAPIError:
assert.Contains(t, typedErr.Error(), expected.Reason())
default:
require.FailNow(t, "got unknown error %+v\nexpected %+v", err, expected)
}
}
func (c *sdkClient) batchCheck(t testing.TB, requestTuples []*ketoapi.RelationTuple) []checkResponse {
request := c.getReadClient().PermissionApi.BatchCheckPermission(c.requestCtx(t)).
BatchCheckPermissionBody(httpclient.BatchCheckPermissionBody{
Tuples: tuplesToRelationships(requestTuples),
})
resp, _, err := request.Execute()
require.NoError(t, err)
responses := make([]checkResponse, len(resp.Results))
for i, result := range resp.Results {
errMsg := ""
if result.Error != nil {
errMsg = *result.Error
}
responses[i] = checkResponse{
allowed: result.Allowed,
errorMessage: errMsg,
}
}
return responses
}
func tuplesToRelationships(tuples []*ketoapi.RelationTuple) []httpclient.Relationship {
relationships := make([]httpclient.Relationship, len(tuples))
for i, requestTuple := range tuples {
relationship := httpclient.Relationship{
Namespace: requestTuple.Namespace,
Object: requestTuple.Object,
Relation: requestTuple.Relation,
SubjectId: requestTuple.SubjectID,
}
if requestTuple.SubjectSet != nil {
relationship.SubjectSet = &httpclient.SubjectSet{
Namespace: requestTuple.SubjectSet.Namespace,
Object: requestTuple.SubjectSet.Object,
Relation: requestTuple.SubjectSet.Relation,
}
}
relationships[i] = relationship
}
return relationships
}
func buildTree(t testing.TB, mt *httpclient.ExpandedPermissionTree) *ketoapi.Tree[*ketoapi.RelationTuple] {
result := &ketoapi.Tree[*ketoapi.RelationTuple]{
Type: ketoapi.TreeNodeType(mt.Type),
}
if mt.Tuple.SubjectSet != nil {
result.Tuple = &ketoapi.RelationTuple{
SubjectSet: &ketoapi.SubjectSet{
Namespace: mt.Tuple.SubjectSet.Namespace,
Object: mt.Tuple.SubjectSet.Object,
Relation: mt.Tuple.SubjectSet.Relation,
},
}
} else {
result.Tuple = &ketoapi.RelationTuple{
SubjectID: mt.Tuple.SubjectId,
}
}
if result.Type != ketoapi.TreeNodeLeaf && len(mt.Children) != 0 {
result.Children = make([]*ketoapi.Tree[*ketoapi.RelationTuple], len(mt.Children))
for i, c := range mt.Children {
c := c
result.Children[i] = buildTree(t, &c)
}
}
return result
}
func (c *sdkClient) expand(t testing.TB, r *ketoapi.SubjectSet, depth int) *ketoapi.Tree[*ketoapi.RelationTuple] {
request := c.getReadClient().PermissionApi.ExpandPermissions(c.requestCtx(t)).
Namespace(r.Namespace).
Object(r.Object).
Relation(r.Relation).
MaxDepth(int64(depth))
resp, _, err := request.Execute()
require.NoError(t, err)
return buildTree(t, resp)
}
func (c *sdkClient) waitUntilLive(t testing.TB) {
resp, _, err := c.getReadClient().MetadataApi.IsReady(c.requestCtx(t)).Execute()
for err != nil {
resp, _, err = c.getReadClient().MetadataApi.IsReady(c.requestCtx(t)).Execute()
}
require.Equal(t, "ok", resp.Status)
}
func (c *sdkClient) queryNamespaces(t testing.TB) (response ketoapi.GetNamespacesResponse) {
res, _, err := c.getReadClient().RelationshipApi.ListRelationshipNamespaces(c.requestCtx(t)).Execute()
require.NoError(t, err)
require.NoError(t, convert(res, &response))
return
}

View File

@@ -0,0 +1,401 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package e2e
import (
"fmt"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/herodot"
"github.com/ory/keto/internal/expand"
"github.com/ory/keto/internal/namespace"
"github.com/ory/keto/ketoapi"
)
func runCases(c client, m *namespaceTestManager) func(*testing.T) {
return func(t *testing.T) {
c.waitUntilLive(t)
t.Run("case=list namespaces", func(t *testing.T) {
first := namespace.Namespace{Name: "my namespace"}
second := namespace.Namespace{Name: "my other namespace"}
m.add(t, &first, &second)
resp := c.queryNamespaces(t)
assert.GreaterOrEqual(t, len(resp.Namespaces), 2)
assertNamespacesContains(t, resp.Namespaces, "my namespace")
assertNamespacesContains(t, resp.Namespaces, "my other namespace")
})
t.Run("case=gets empty namespace", func(t *testing.T) {
n := &namespace.Namespace{Name: t.Name()}
m.add(t, n)
resp := c.queryTuple(t, &ketoapi.RelationQuery{Namespace: &n.Name})
assert.Len(t, resp.RelationTuples, 0)
})
t.Run("case=creates tuple and uses it then", func(t *testing.T) {
n := &namespace.Namespace{Name: t.Name()}
m.add(t, n)
tuple := &ketoapi.RelationTuple{
Namespace: n.Name,
Object: fmt.Sprintf("object for client %T", c),
Relation: "access",
SubjectID: new("client"),
}
c.createTuple(t, tuple)
resp := c.queryTuple(t, &ketoapi.RelationQuery{Namespace: &tuple.Namespace})
require.Len(t, resp.RelationTuples, 1)
assert.Equal(t, tuple, resp.RelationTuples[0])
// try the check API to see whether the tuple is interpreted correctly
assert.True(t, c.check(t, tuple))
batchResult := c.batchCheck(t, []*ketoapi.RelationTuple{tuple})
require.Len(t, batchResult, 1)
assert.True(t, batchResult[0].allowed)
assert.Empty(t, batchResult[0].errorMessage)
})
t.Run("case=creates tuple with empty IDs", func(t *testing.T) {
n := &namespace.Namespace{Name: t.Name()}
m.add(t, n)
tuples := []*ketoapi.RelationTuple{{
Namespace: n.Name,
Object: "",
Relation: "access",
SubjectID: new(""),
}, {
Namespace: n.Name,
Object: "",
Relation: "access",
SubjectSet: &ketoapi.SubjectSet{
Namespace: n.Name,
Object: "",
Relation: "access",
},
}}
for _, tp := range tuples {
c.createTuple(t, tp)
// try the check API to see whether the tuple is interpreted correctly
assert.True(t, c.check(t, tp))
batchResult := c.batchCheck(t, []*ketoapi.RelationTuple{tp})
require.Len(t, batchResult, 1)
assert.True(t, batchResult[0].allowed)
assert.Empty(t, batchResult[0].errorMessage)
}
resp := c.queryTuple(t, &ketoapi.RelationQuery{Namespace: &n.Name})
assert.ElementsMatch(t, tuples, resp.RelationTuples)
})
t.Run("case=check subjectSet relations", func(t *testing.T) {
n := &namespace.Namespace{Name: t.Name()}
m.add(t, n)
obj := fmt.Sprintf("obj for client %T", c)
rel := "check"
rt := &ketoapi.RelationTuple{
Namespace: n.Name,
Object: obj,
Relation: rel,
SubjectSet: &ketoapi.SubjectSet{
Namespace: n.Name,
Object: obj,
Relation: rel,
},
}
c.createTuple(t, rt)
assert.True(t, c.check(t, rt))
batchResult := c.batchCheck(t, []*ketoapi.RelationTuple{rt})
require.Len(t, batchResult, 1)
assert.True(t, batchResult[0].allowed)
assert.Empty(t, batchResult[0].errorMessage)
})
t.Run("case=batch check", func(t *testing.T) {
/*
Test batch check with four cases:
- Allowed single subject tuple
- Allowed subject set tuple
- Tuple with unknown namespace (will return an error for this case)
- Dis-allowed tuple
*/
namespace1 := &namespace.Namespace{Name: t.Name()}
namespace2 := &namespace.Namespace{Name: t.Name() + "-2"}
m.add(t, namespace1)
m.add(t, namespace2)
obj1 := fmt.Sprintf("obj for client %T", c)
obj2 := fmt.Sprintf("another obj for client %T", c)
rel1 := "check"
rel2 := "access"
tupleSubjectSet := &ketoapi.RelationTuple{
Namespace: namespace1.Name,
Object: obj1,
Relation: rel1,
SubjectSet: &ketoapi.SubjectSet{
Namespace: namespace1.Name,
Object: obj1,
Relation: rel1,
},
}
c.createTuple(t, tupleSubjectSet)
tupleSingleSubject := &ketoapi.RelationTuple{
Namespace: namespace2.Name,
Object: obj2,
Relation: rel2,
SubjectID: new("sub1"),
}
c.createTuple(t, tupleSingleSubject)
unknownNamespaceTuple := &ketoapi.RelationTuple{
Namespace: "unknown-namespace",
Object: obj1,
Relation: rel1,
SubjectID: new("sub1"),
}
unknownSubjectTuple := &ketoapi.RelationTuple{
Namespace: namespace1.Name,
Object: obj1,
Relation: rel1,
SubjectID: new("unknown-sub"),
}
batchResult := c.batchCheck(t, []*ketoapi.RelationTuple{
tupleSubjectSet, tupleSingleSubject,
unknownNamespaceTuple, unknownSubjectTuple,
})
require.Len(t, batchResult, 4)
assert.True(t, batchResult[0].allowed)
assert.Empty(t, batchResult[0].errorMessage)
assert.True(t, batchResult[1].allowed)
assert.Empty(t, batchResult[1].errorMessage)
assert.False(t, batchResult[2].allowed)
assert.Contains(t, batchResult[2].errorMessage, "The requested resource could not be found")
assert.False(t, batchResult[3].allowed)
assert.Empty(t, batchResult[3].errorMessage)
// Verify a call with no tuples returns successfully with no results
emptyResults := c.batchCheck(t, []*ketoapi.RelationTuple{})
require.Empty(t, emptyResults)
})
t.Run("case=batch check validation errors", func(t *testing.T) {
// Pass in 11 tuples to check, more than the default limit of 10, and verify the request is rejected
tuples := make([]*ketoapi.RelationTuple, 11)
for i := range tuples {
tuples[i] = &ketoapi.RelationTuple{
Namespace: "namespace-name",
Object: "obj",
Relation: "rel",
SubjectID: new("sub"),
}
}
c.batchCheckErr(t, tuples, herodot.ErrBadRequest())
})
t.Run("case=expand API", func(t *testing.T) {
n := &namespace.Namespace{Name: t.Name()}
m.add(t, n)
obj := fmt.Sprintf("tree for client %T", c)
rel := "expand"
subjects := []string{"s1", "s2"}
expectedTree := &ketoapi.Tree[*ketoapi.RelationTuple]{
Type: ketoapi.TreeNodeUnion,
Tuple: &ketoapi.RelationTuple{
SubjectSet: &ketoapi.SubjectSet{
Namespace: n.Name,
Object: obj,
Relation: rel,
},
},
Children: make([]*ketoapi.Tree[*ketoapi.RelationTuple], len(subjects)),
}
for i, subjectID := range subjects {
c.createTuple(t, &ketoapi.RelationTuple{
Namespace: n.Name,
Object: obj,
Relation: rel,
SubjectID: &subjectID,
})
expectedTree.Children[i] = &ketoapi.Tree[*ketoapi.RelationTuple]{
Type: ketoapi.TreeNodeLeaf,
Tuple: &ketoapi.RelationTuple{
SubjectID: &subjectID,
},
}
}
actualTree := c.expand(t, expectedTree.Tuple.SubjectSet, 100)
assert.Equal(t, expectedTree.Type, actualTree.Type)
assert.Equal(t, expectedTree.Tuple, actualTree.Tuple)
assert.Len(t, actualTree.Children, len(expectedTree.Children))
expand.AssertExternalTreesAreEqual(t, expectedTree, actualTree)
})
t.Run("case=gets result paginated", func(t *testing.T) {
const nTuples = 10
n := &namespace.Namespace{Name: t.Name()}
m.add(t, n)
rel := fmt.Sprintf("some unique relation %T", c)
for i := 0; i < nTuples; i++ {
c.createTuple(t, &ketoapi.RelationTuple{
Namespace: n.Name,
Object: "o" + strconv.Itoa(i),
Relation: rel,
SubjectID: new("s" + strconv.Itoa(i)),
})
}
var (
resp ketoapi.GetResponse
nPages int
)
// do ... while resp.NextPageToken != ""
for ok := true; ok; ok = resp.NextPageToken != "" {
resp = *c.queryTuple(t,
&ketoapi.RelationQuery{
Namespace: &n.Name,
Relation: &rel,
},
withToken(resp.NextPageToken),
withSize(1),
)
nPages++
assert.Len(t, resp.RelationTuples, 1)
}
assert.Equal(t, nTuples, nPages)
})
t.Run("case=deletes tuple", func(t *testing.T) {
n := &namespace.Namespace{Name: t.Name()}
m.add(t, n)
for _, rt := range []*ketoapi.RelationTuple{
{
SubjectID: new("s"),
},
{
SubjectSet: &ketoapi.SubjectSet{
Namespace: n.Name,
Object: "so",
Relation: "rel",
},
},
} {
t.Run(fmt.Sprintf("subject_id=%v", rt.SubjectID == nil), func(t *testing.T) {
rt.Namespace = n.Name
rt.Object = "o"
rt.Relation = "rel"
c.createTuple(t, rt)
resp := c.queryTuple(t, &ketoapi.RelationQuery{Namespace: &n.Name})
assert.Equal(t, []*ketoapi.RelationTuple{rt}, resp.RelationTuples)
c.deleteTuple(t, rt)
resp = c.queryTuple(t, &ketoapi.RelationQuery{Namespace: &n.Name})
assert.Len(t, resp.RelationTuples, 0)
})
}
})
t.Run("case=deletes tuples based on relation query", func(t *testing.T) {
n := &namespace.Namespace{Name: t.Name()}
m.add(t, n)
rts := []*ketoapi.RelationTuple{
{
Namespace: n.Name,
Object: "o1",
Relation: "rel",
SubjectID: new("s1"),
},
{
Namespace: n.Name,
Object: "o2",
Relation: "rel",
SubjectID: new("s2"),
},
}
for i := 0; i < len(rts); i++ {
c.createTuple(t, rts[i])
}
q := &ketoapi.RelationQuery{
Namespace: &n.Name,
Relation: new("rel"),
}
resp := c.queryTuple(t, q)
require.ElementsMatch(t, resp.RelationTuples, rts)
c.deleteAllTuples(t, q)
resp = c.queryTuple(t, q)
assert.Equal(t, 0, len(resp.RelationTuples))
})
t.Run("case=returns error with status code on unknown namespace", func(t *testing.T) {
c.queryTupleErr(t, herodot.ErrNotFound(), &ketoapi.RelationQuery{Namespace: new("unknown namespace")})
})
t.Run("case=still serves tuples from deleted namespace", func(t *testing.T) {
n := &namespace.Namespace{Name: t.Name()}
m.add(t, n)
tuple := &ketoapi.RelationTuple{
Namespace: n.Name,
Object: "o",
Relation: "rel",
SubjectID: new("s"),
}
c.createTuple(t, tuple)
m.remove(t, n.Name)
resp := c.queryTuple(t, &ketoapi.RelationQuery{})
assert.Equal(t, []*ketoapi.RelationTuple{tuple}, resp.RelationTuples)
// Add the namespace again here, so that we can clean up properly.
m.add(t, n)
})
t.Run("case=OPL syntax check", func(t *testing.T) {
parseErrors := c.oplCheckSyntax(t, []byte("/* unclosed comment"))
require.Len(t, parseErrors, 1)
assert.Contains(t, parseErrors[0].Message, "unclosed comment")
})
}
}
func assertNamespacesContains(t *testing.T, list []ketoapi.Namespace, name string) {
t.Helper()
for _, n := range list {
if n.Name == name {
return
}
}
t.Errorf("Could not find %q in %+v", name, list)
}

View File

@@ -0,0 +1,232 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package e2e
import (
"cmp"
"slices"
"strconv"
"testing"
"time"
"github.com/ory/x/uuidx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/keto/internal/namespace"
"github.com/ory/keto/ketoapi"
)
func runTransactionCases(c transactClient, m *namespaceTestManager) func(*testing.T) {
return func(t *testing.T) {
t.Parallel()
t.Run("case=create and delete", func(t *testing.T) {
n := &namespace.Namespace{Name: t.Name()}
m.add(t, n)
tuples := []*ketoapi.RelationTuple{
{
Namespace: n.Name,
Object: "o",
Relation: "rel",
SubjectSet: &ketoapi.SubjectSet{
Namespace: n.Name,
Object: "o",
Relation: "rel",
},
},
{
Namespace: n.Name,
Object: "o",
Relation: "rel",
SubjectID: new("sid"),
},
}
c.transactTuples(t, tuples, nil)
resp := c.queryTuple(t, &ketoapi.RelationQuery{
Namespace: &n.Name,
})
for i := range tuples {
assert.Contains(t, resp.RelationTuples, tuples[i])
}
c.transactTuples(t, nil, tuples)
resp = c.queryTuple(t, &ketoapi.RelationQuery{
Namespace: &n.Name,
})
assert.Len(t, resp.RelationTuples, 0)
})
t.Run("case=duplicate string representations", func(t *testing.T) {
n := &namespace.Namespace{Name: t.Name()}
m.add(t, n)
c.transactTuples(t, []*ketoapi.RelationTuple{
{
Namespace: n.Name,
Object: "o",
Relation: "rel",
SubjectID: new("sid"),
},
{
Namespace: n.Name,
Object: "o",
Relation: "rel",
SubjectID: new("sid"),
},
}, nil)
})
t.Run("case=large inserts and deletes", func(t *testing.T) {
if testing.Short() {
t.Skip("This test is fairly expensive, especially the deletion.")
}
ns := []*namespace.Namespace{
{Name: uuidx.NewV4().String()},
{Name: uuidx.NewV4().String()},
}
m.add(t, ns...)
var tuples []*ketoapi.RelationTuple
for i := range 1200 {
tuples = append(tuples,
&ketoapi.RelationTuple{
Namespace: ns[0].Name,
Object: "o" + strconv.Itoa(i),
Relation: "rela",
SubjectSet: &ketoapi.SubjectSet{
Namespace: ns[1].Name,
Object: "o" + strconv.Itoa(i),
Relation: "relx",
},
},
&ketoapi.RelationTuple{
Namespace: ns[0].Name,
Object: "o" + strconv.Itoa(i),
Relation: "relb",
SubjectID: new("sid"),
},
)
}
t0 := time.Now()
c.transactTuples(t, tuples, nil)
t.Logf("insert took %s", time.Since(t0))
t0 = time.Now()
var resp []*ketoapi.RelationTuple
var pt string
for {
r := c.queryTuple(t, &ketoapi.RelationQuery{
Namespace: &ns[0].Name,
}, withSize(1000), withToken(pt))
resp = append(resp, r.RelationTuples...)
pt = r.NextPageToken
if pt == "" {
break
}
}
t.Logf("query took %s", time.Since(t0))
sort := func(a, b *ketoapi.RelationTuple) int {
return cmp.Or(
cmp.Compare(a.Namespace, b.Namespace),
cmp.Compare(a.Object, b.Object),
cmp.Compare(a.Relation, b.Relation),
)
}
slices.SortFunc(resp, sort)
slices.SortFunc(tuples, sort)
require.Equal(t, tuples, resp)
t0 = time.Now()
c.transactTuples(t, nil, tuples)
t.Logf("delete took %s", time.Since(t0))
resp = c.queryTuple(t, &ketoapi.RelationQuery{
Namespace: &ns[0].Name,
}).RelationTuples
assert.Len(t, resp, 0)
})
t.Run("case=expand-api-display-access docs code sample", func(t *testing.T) {
files := &namespace.Namespace{Name: t.Name() + "files"}
directories := &namespace.Namespace{Name: t.Name() + "directories"}
m.add(t, files, directories)
tuples := []*ketoapi.RelationTuple{
{
Namespace: directories.Name,
Object: "/photos",
Relation: "owner",
SubjectID: new("maureen"),
},
{
Namespace: files.Name,
Object: "/photos/beach.jpg",
Relation: "owner",
SubjectID: new("maureen"),
},
{
Namespace: files.Name,
Object: "/photos/mountains.jpg",
Relation: "owner",
SubjectID: new("laura"),
},
{
Namespace: directories.Name,
Object: "/photos",
Relation: "access",
SubjectID: new("laura"),
},
}
for _, o := range []struct{ n, o string }{
{files.Name, "/photos/beach.jpg"},
{files.Name, "/photos/mountains.jpg"},
{directories.Name, "/photos"},
} {
tuples = append(tuples, &ketoapi.RelationTuple{
Namespace: o.n,
Object: o.o,
Relation: "access",
SubjectSet: &ketoapi.SubjectSet{
Namespace: o.n,
Object: o.o,
Relation: "owner",
},
})
}
for _, obj := range []string{"/photos/beach.jpg", "/photos/mountains.jpg"} {
tuples = append(tuples, &ketoapi.RelationTuple{
Namespace: files.Name,
Object: obj,
Relation: "access",
SubjectSet: &ketoapi.SubjectSet{
Namespace: directories.Name,
Object: "/photos",
Relation: "access",
},
})
}
c.transactTuples(t, tuples, nil)
fileTuples := c.queryTuple(t, &ketoapi.RelationQuery{Namespace: new(files.Name)})
directoryTuples := c.queryTuple(t, &ketoapi.RelationQuery{Namespace: new(directories.Name)})
allTuples := append(fileTuples.RelationTuples, directoryTuples.RelationTuples...)
assert.ElementsMatch(t, allTuples, tuples)
c.transactTuples(t, nil, tuples)
fileTuples = c.queryTuple(t, &ketoapi.RelationQuery{Namespace: new(files.Name)})
directoryTuples = c.queryTuple(t, &ketoapi.RelationQuery{Namespace: new(directories.Name)})
allTuples = append(fileTuples.RelationTuples, directoryTuples.RelationTuples...)
assert.Len(t, allTuples, 0)
})
}
}