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:
155
internal/e2e/cli_client_test.go
Normal file
155
internal/e2e/cli_client_test.go
Normal 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")...)
|
||||
}
|
||||
160
internal/e2e/full_suit_test.go
Normal file
160
internal/e2e/full_suit_test.go
Normal 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)
|
||||
}
|
||||
285
internal/e2e/grpc_client_test.go
Normal file
285
internal/e2e/grpc_client_test.go
Normal 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
164
internal/e2e/helpers.go
Normal 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
|
||||
}
|
||||
222
internal/e2e/rest_client_test.go
Normal file
222
internal/e2e/rest_client_test.go
Normal 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)
|
||||
}
|
||||
360
internal/e2e/sdk_client_test.go
Normal file
360
internal/e2e/sdk_client_test.go
Normal 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
|
||||
}
|
||||
401
internal/e2e/testcases_test.go
Normal file
401
internal/e2e/testcases_test.go
Normal 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)
|
||||
}
|
||||
232
internal/e2e/transaction_cases_test.go
Normal file
232
internal/e2e/transaction_cases_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user