test(invocation): verify arguments versus aggregated policies
This commit is contained in:
@@ -7,11 +7,8 @@ import (
|
|||||||
"github.com/ipfs/go-cid"
|
"github.com/ipfs/go-cid"
|
||||||
"github.com/ipld/go-ipld-prime"
|
"github.com/ipld/go-ipld-prime"
|
||||||
"github.com/ipld/go-ipld-prime/codec/dagjson"
|
"github.com/ipld/go-ipld-prime/codec/dagjson"
|
||||||
"github.com/ipld/go-ipld-prime/datamodel"
|
|
||||||
"github.com/ipld/go-ipld-prime/fluent/qp"
|
|
||||||
cidlink "github.com/ipld/go-ipld-prime/linking/cid"
|
cidlink "github.com/ipld/go-ipld-prime/linking/cid"
|
||||||
"github.com/ipld/go-ipld-prime/node/basicnode"
|
"github.com/ipld/go-ipld-prime/node/basicnode"
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
|
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
|
||||||
@@ -904,55 +901,3 @@ func TestPartialMatch(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestInvocationValidation applies the example policy to the second
|
|
||||||
// example arguments as defined in the [Validation] section of the
|
|
||||||
// invocation specification.
|
|
||||||
//
|
|
||||||
// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation
|
|
||||||
func TestInvocationValidationSpecExamples(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
pol := MustConstruct(
|
|
||||||
Equal(".from", literal.String("alice@example.com")),
|
|
||||||
Any(".to", Like(".", "*@example.com")),
|
|
||||||
)
|
|
||||||
|
|
||||||
t.Run("with passing args", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) {
|
|
||||||
qp.MapEntry(ma, "from", qp.String("alice@example.com"))
|
|
||||||
qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) {
|
|
||||||
qp.ListEntry(la, qp.String("bob@example.com"))
|
|
||||||
qp.ListEntry(la, qp.String("carol@not.example.com"))
|
|
||||||
}))
|
|
||||||
qp.MapEntry(ma, "title", qp.String("Coffee"))
|
|
||||||
qp.MapEntry(ma, "body", qp.String("Still on for coffee"))
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
exec, stmt := pol.Match(argsNode)
|
|
||||||
assert.True(t, exec)
|
|
||||||
assert.Nil(t, stmt)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("fails on recipients (second statement)", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) {
|
|
||||||
qp.MapEntry(ma, "from", qp.String("alice@example.com"))
|
|
||||||
qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) {
|
|
||||||
qp.ListEntry(la, qp.String("bob@null.com"))
|
|
||||||
qp.ListEntry(la, qp.String("carol@elsewhere.example.com"))
|
|
||||||
}))
|
|
||||||
qp.MapEntry(ma, "title", qp.String("Coffee"))
|
|
||||||
qp.MapEntry(ma, "body", qp.String("Still on for coffee"))
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
exec, stmt := pol.Match(argsNode)
|
|
||||||
assert.False(t, exec)
|
|
||||||
assert.NotNil(t, stmt)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
98
pkg/policy/policytest/example.go
Normal file
98
pkg/policy/policytest/example.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package policytest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/ipld/go-ipld-prime"
|
||||||
|
"github.com/ucan-wg/go-ucan/pkg/args"
|
||||||
|
"github.com/ucan-wg/go-ucan/pkg/policy"
|
||||||
|
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EmptyPolicy provides a Policy with no statements.
|
||||||
|
var EmptyPolicy = policy.Policy{}
|
||||||
|
|
||||||
|
// ExampleValidationPolicy provides a instantiated Policy containing the
|
||||||
|
// statements that are included in the second code block of the [Validation]
|
||||||
|
// section of the delegation specification.
|
||||||
|
//
|
||||||
|
// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation
|
||||||
|
var ExamplePolicy = policy.MustConstruct(
|
||||||
|
policy.Equal(".from", literal.String("alice@example.com")),
|
||||||
|
policy.Any(".to", policy.Like(".", "*@example.com")),
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: Replace the URL for [Validation] above when the delegation
|
||||||
|
// specification has been finished/merged.
|
||||||
|
|
||||||
|
// ExampleValidArguments provides valid, instantiated Arguments containing
|
||||||
|
// the key/value pairs that are included in portion of the the second code
|
||||||
|
// block of the [Validation] section of the delegation specification.
|
||||||
|
//
|
||||||
|
// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation
|
||||||
|
var ExampleValidArguments = newBuilder(nil).
|
||||||
|
add("from", "alice@example.com").
|
||||||
|
add("to", []string{
|
||||||
|
"bob@example.com",
|
||||||
|
"carol@not.example.com",
|
||||||
|
}).
|
||||||
|
add("title", "Coffee").
|
||||||
|
add("body", "Still on for coffee").
|
||||||
|
mustBuild()
|
||||||
|
|
||||||
|
var exampleValidArgumentsIPLD = mustIPLD(ExampleValidArguments)
|
||||||
|
|
||||||
|
// ExampleInvalidArguments provides invalid, instantiated Arguments containing
|
||||||
|
// the key/value pairs that are included in portion of the the second code
|
||||||
|
// block of the [Validation] section of the delegation specification.
|
||||||
|
//
|
||||||
|
// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation
|
||||||
|
var ExampleInvalidArguments = newBuilder(nil).
|
||||||
|
add("from", "alice@example.com").
|
||||||
|
add("to", []string{
|
||||||
|
"bob@null.com",
|
||||||
|
"carol@elsewhere.example.com",
|
||||||
|
}).
|
||||||
|
add("title", "Coffee").
|
||||||
|
add("body", "Still on for coffee").
|
||||||
|
mustBuild()
|
||||||
|
|
||||||
|
var exampleInvalidArgumentsIPLD = mustIPLD(ExampleInvalidArguments)
|
||||||
|
|
||||||
|
type builder struct {
|
||||||
|
args *args.Args
|
||||||
|
errs error
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBuilder(a *args.Args) *builder {
|
||||||
|
if a == nil {
|
||||||
|
a = args.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &builder{
|
||||||
|
args: a,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *builder) add(key string, val any) *builder {
|
||||||
|
b.errs = errors.Join(b.errs, b.args.Add(key, val))
|
||||||
|
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *builder) mustBuild() *args.Args {
|
||||||
|
if b.errs != nil {
|
||||||
|
panic(b.errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.args
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustIPLD(args *args.Args) ipld.Node {
|
||||||
|
node, err := args.ToIPLD()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return node
|
||||||
|
}
|
||||||
32
pkg/policy/policytest/example_test.go
Normal file
32
pkg/policy/policytest/example_test.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package policytest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestInvocationValidation applies the example policy to the second
|
||||||
|
// example arguments as defined in the [Validation] section of the
|
||||||
|
// invocation specification.
|
||||||
|
//
|
||||||
|
// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation
|
||||||
|
func TestInvocationValidationSpecExamples(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("with passing args", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
exec, stmt := ExamplePolicy.Match(exampleValidArgumentsIPLD)
|
||||||
|
assert.True(t, exec)
|
||||||
|
assert.Nil(t, stmt)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fails on recipients (second statement)", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
exec, stmt := ExamplePolicy.Match(exampleInvalidArgumentsIPLD)
|
||||||
|
assert.False(t, exec)
|
||||||
|
assert.NotNil(t, stmt)
|
||||||
|
})
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/ucan-wg/go-ucan/did/didtest"
|
"github.com/ucan-wg/go-ucan/did/didtest"
|
||||||
"github.com/ucan-wg/go-ucan/pkg/command"
|
"github.com/ucan-wg/go-ucan/pkg/command"
|
||||||
"github.com/ucan-wg/go-ucan/pkg/policy"
|
"github.com/ucan-wg/go-ucan/pkg/policy"
|
||||||
|
"github.com/ucan-wg/go-ucan/pkg/policy/policytest"
|
||||||
"github.com/ucan-wg/go-ucan/token/delegation"
|
"github.com/ucan-wg/go-ucan/token/delegation"
|
||||||
"github.com/ucan-wg/go-ucan/token/delegation/delegationtest"
|
"github.com/ucan-wg/go-ucan/token/delegation/delegationtest"
|
||||||
)
|
)
|
||||||
@@ -86,7 +87,7 @@ func (g *generator) chainPersonas(personas []didtest.Persona, acc acc, vari vari
|
|||||||
privKey: personas[0].PrivKey(),
|
privKey: personas[0].PrivKey(),
|
||||||
aud: personas[1].DID(),
|
aud: personas[1].DID(),
|
||||||
cmd: delegationtest.NominalCommand,
|
cmd: delegationtest.NominalCommand,
|
||||||
pol: policy.Policy{},
|
pol: policytest.EmptyPolicy,
|
||||||
opts: []delegation.Option{
|
opts: []delegation.Option{
|
||||||
delegation.WithSubject(didtest.PersonaAlice.DID()),
|
delegation.WithSubject(didtest.PersonaAlice.DID()),
|
||||||
delegation.WithNonce(constantNonce),
|
delegation.WithNonce(constantNonce),
|
||||||
@@ -128,6 +129,9 @@ func (g *generator) chainPersonas(personas []didtest.Persona, acc acc, vari vari
|
|||||||
}
|
}
|
||||||
p.opts = append(p.opts, delegation.WithNotBefore(nbf))
|
p.opts = append(p.opts, delegation.WithNotBefore(nbf))
|
||||||
}},
|
}},
|
||||||
|
{name: "ValidExamplePolicy", variant: func(p *newDelegationParams) {
|
||||||
|
p.pol = policytest.ExamplePolicy
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start a branch in the recursion for each of the variants
|
// Start a branch in the recursion for each of the variants
|
||||||
|
|||||||
@@ -75,17 +75,17 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
TokenCarolDan_InvalidExpiredCID = gocid.MustParse("bafyreigenypixaxvhzlry5rjnywvjyl4xvzlzxz2ui74uzys7qdhos4bbu")
|
TokenCarolDan_InvalidExpiredCID = gocid.MustParse("bafyreifrbm6bgyqdzhhcubbb7dnhq3aq6udvdbfs7mhqjs3d2ihraelufu")
|
||||||
TokenCarolDan_InvalidExpired = mustGetDelegation(TokenCarolDan_InvalidExpiredCID)
|
TokenCarolDan_InvalidExpired = mustGetDelegation(TokenCarolDan_InvalidExpiredCID)
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
TokenDanErin_InvalidExpiredCID = gocid.MustParse("bafyreifvnfb7zqocpdysedcvjkb4y7tqfuziuqjhbbdoay4zg33pwpbzqi")
|
TokenDanErin_InvalidExpiredCID = gocid.MustParse("bafyreibbh5ujs6udphkl3exffohxsg5mdknoqzjb3gdhmuncg3qnomzemy")
|
||||||
TokenDanErin_InvalidExpired = mustGetDelegation(TokenDanErin_InvalidExpiredCID)
|
TokenDanErin_InvalidExpired = mustGetDelegation(TokenDanErin_InvalidExpiredCID)
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
TokenErinFrank_InvalidExpiredCID = gocid.MustParse("bafyreicvydzt3obkqx7krmoi3zu4tlirlksibxfks5jc7vlvjxjamv2764")
|
TokenErinFrank_InvalidExpiredCID = gocid.MustParse("bafyreiggzczmqlybhxljmlfot5t7o4w6fhdv7fme77a466ku73dhxtqzdq")
|
||||||
TokenErinFrank_InvalidExpired = mustGetDelegation(TokenErinFrank_InvalidExpiredCID)
|
TokenErinFrank_InvalidExpired = mustGetDelegation(TokenErinFrank_InvalidExpiredCID)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -104,6 +104,21 @@ var (
|
|||||||
TokenErinFrank_InvalidInactive = mustGetDelegation(TokenErinFrank_InvalidInactiveCID)
|
TokenErinFrank_InvalidInactive = mustGetDelegation(TokenErinFrank_InvalidInactiveCID)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
TokenCarolDan_ValidExamplePolicyCID = gocid.MustParse("bafyreibtfrp2njnkjrcuhxd4ebaecmpcql5knek2h2j2fjzu2sij2tv6ei")
|
||||||
|
TokenCarolDan_ValidExamplePolicy = mustGetDelegation(TokenCarolDan_ValidExamplePolicyCID)
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
TokenDanErin_ValidExamplePolicyCID = gocid.MustParse("bafyreidxfwbkzujpu7ivulkc7b6ff4cpbzrkeklmxqvyhhmkmym5b45e2e")
|
||||||
|
TokenDanErin_ValidExamplePolicy = mustGetDelegation(TokenDanErin_ValidExamplePolicyCID)
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
TokenErinFrank_ValidExamplePolicyCID = gocid.MustParse("bafyreiatkvtvgakqcrdk6vgrv7tbq5rbeiqct52ep4plcftp2agffjyvp4")
|
||||||
|
TokenErinFrank_ValidExamplePolicy = mustGetDelegation(TokenErinFrank_ValidExamplePolicyCID)
|
||||||
|
)
|
||||||
|
|
||||||
var ProofAliceBob = []gocid.Cid{
|
var ProofAliceBob = []gocid.Cid{
|
||||||
TokenAliceBobCID,
|
TokenAliceBobCID,
|
||||||
}
|
}
|
||||||
@@ -238,3 +253,24 @@ var ProofAliceBobCarolDanErinFrank_InvalidInactive = []gocid.Cid{
|
|||||||
TokenBobCarolCID,
|
TokenBobCarolCID,
|
||||||
TokenAliceBobCID,
|
TokenAliceBobCID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ProofAliceBobCarolDan_ValidExamplePolicy = []gocid.Cid{
|
||||||
|
TokenCarolDan_ValidExamplePolicyCID,
|
||||||
|
TokenBobCarolCID,
|
||||||
|
TokenAliceBobCID,
|
||||||
|
}
|
||||||
|
|
||||||
|
var ProofAliceBobCarolDanErin_ValidExamplePolicy = []gocid.Cid{
|
||||||
|
TokenDanErin_ValidExamplePolicyCID,
|
||||||
|
TokenCarolDan_ValidExamplePolicyCID,
|
||||||
|
TokenBobCarolCID,
|
||||||
|
TokenAliceBobCID,
|
||||||
|
}
|
||||||
|
|
||||||
|
var ProofAliceBobCarolDanErinFrank_ValidExamplePolicy = []gocid.Cid{
|
||||||
|
TokenErinFrank_ValidExamplePolicyCID,
|
||||||
|
TokenDanErin_ValidExamplePolicyCID,
|
||||||
|
TokenCarolDan_ValidExamplePolicyCID,
|
||||||
|
TokenBobCarolCID,
|
||||||
|
TokenAliceBobCID,
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,3 +35,7 @@ var (
|
|||||||
// next delegation or invocation's command.
|
// next delegation or invocation's command.
|
||||||
ErrCommandNotCovered = errors.New("allowed command doesn't cover the next delegation or invocation")
|
ErrCommandNotCovered = errors.New("allowed command doesn't cover the next delegation or invocation")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrPolicyNotSatisfied is returned when the provided Arguments don't
|
||||||
|
// satisfy one or more of the aggregated Policy Statements
|
||||||
|
var ErrPolicyNotSatisfied = errors.New("the following UCAN policy is not satisfied")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/ucan-wg/go-ucan/did/didtest"
|
"github.com/ucan-wg/go-ucan/did/didtest"
|
||||||
"github.com/ucan-wg/go-ucan/pkg/args"
|
"github.com/ucan-wg/go-ucan/pkg/args"
|
||||||
"github.com/ucan-wg/go-ucan/pkg/command"
|
"github.com/ucan-wg/go-ucan/pkg/command"
|
||||||
|
"github.com/ucan-wg/go-ucan/pkg/policy/policytest"
|
||||||
"github.com/ucan-wg/go-ucan/token/delegation/delegationtest"
|
"github.com/ucan-wg/go-ucan/token/delegation/delegationtest"
|
||||||
"github.com/ucan-wg/go-ucan/token/invocation"
|
"github.com/ucan-wg/go-ucan/token/invocation"
|
||||||
)
|
)
|
||||||
@@ -48,6 +49,18 @@ func TestToken_ExecutionAllowed(t *testing.T) {
|
|||||||
testPasses(t, didtest.PersonaFrank, delegationtest.AttenuatedCommand, emptyArguments, delegationtest.ProofAliceBobCarolDanErinFrank)
|
testPasses(t, didtest.PersonaFrank, delegationtest.AttenuatedCommand, emptyArguments, delegationtest.ProofAliceBobCarolDanErinFrank)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("passes - arguments satisfy empty policy", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testPasses(t, didtest.PersonaFrank, delegationtest.NominalCommand, policytest.ExampleValidArguments, delegationtest.ProofAliceBobCarolDanErinFrank)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("passes - arguments satify example policy", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testPasses(t, didtest.PersonaFrank, delegationtest.NominalCommand, policytest.ExampleValidArguments, delegationtest.ProofAliceBobCarolDanErinFrank_ValidExamplePolicy)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("fails - no proof", func(t *testing.T) {
|
t.Run("fails - no proof", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -115,12 +128,19 @@ func TestToken_ExecutionAllowed(t *testing.T) {
|
|||||||
|
|
||||||
testFails(t, invocation.ErrWrongSub, didtest.PersonaFrank, delegationtest.ExpandedCommand, emptyArguments, delegationtest.ProofAliceBobCarolDanErinFrank_InvalidSubject)
|
testFails(t, invocation.ErrWrongSub, didtest.PersonaFrank, delegationtest.ExpandedCommand, emptyArguments, delegationtest.ProofAliceBobCarolDanErinFrank_InvalidSubject)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("passes - arguments satify example policy", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testFails(t, invocation.ErrPolicyNotSatisfied, didtest.PersonaFrank, delegationtest.NominalCommand, policytest.ExampleInvalidArguments, delegationtest.ProofAliceBobCarolDanErinFrank_ValidExamplePolicy)
|
||||||
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func test(t *testing.T, persona didtest.Persona, cmd command.Command, args *args.Args, prf []cid.Cid, opts ...invocation.Option) error {
|
func test(t *testing.T, persona didtest.Persona, cmd command.Command, args *args.Args, prf []cid.Cid, opts ...invocation.Option) error {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// TODO: use the args and add minimal test to check that they are verified against the policy
|
opts = append(opts, invocation.WithArguments(args))
|
||||||
|
|
||||||
tkn, err := invocation.New(persona.DID(), didtest.PersonaAlice.DID(), cmd, prf, opts...)
|
tkn, err := invocation.New(persona.DID(), didtest.PersonaAlice.DID(), cmd, prf, opts...)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ func (t *Token) verifyArgs(delegations []*delegation.Token, arguments *args.Args
|
|||||||
|
|
||||||
ok, statement := policies.Match(argsIpld)
|
ok, statement := policies.Match(argsIpld)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("the following UCAN policy is not satisfied: %v", statement.String())
|
return fmt.Errorf("%w: %v", ErrPolicyNotSatisfied, statement.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
Reference in New Issue
Block a user