mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-20 22:44:25 +00:00
This pull request introduces a new feature that allows adding, updating, and querying project grants The changes are primarily in the backend and include new database tables, repositories, and domain logic to support project grants. ## Changes * New `project_grants` table: A new table `zitadel.project_grants` is introduced to store project grants It includes columns for `instance_id`, `id`, `state`, `project_id`, `granted_org_id` and `granting_org_id`. * New `ProjectGrantRepository`: A new repository `ProjectGrantRepository` is created to handle all database operations for project grants. It provides methods to `Get`, `List`, `Create`, `Update`, and `Delete`. * New `project_grant_roles` table: A new table `zitadel.project_grant_roles` is introduced to store project grant roles It includes columns for `instance_id`, `grant_id`, `key`, `project_id` and `project_org_id`. * New `ProjectGrantRoleRepository`: A new repository `ProjectGrantRoleRepository` is created to handle all database operations for project grant roles. It provides methods to `Get`, `List`, `Add`, and `Delete`. * New `project_grant_relational_projection`: A new projection `project_grant_relational_projection` is added to update the `zitadel.project_grants` and `zitadel.project_grant_roles` tables based on events. It handles all project grant events. * Updated `ProjectRepository`: The `ProjectRepository` is updated to list granted projects. # Additional Context Closes https://github.com/zitadel/zitadel/issues/10766 --------- Co-authored-by: Stefan Benz <[email protected]> Co-authored-by: Marco A. <[email protected]> Co-authored-by: Silvan <[email protected]>
590 lines
18 KiB
Go
590 lines
18 KiB
Go
package repository_test
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/brianvoe/gofakeit/v6"
|
|
"github.com/muhlemmer/gu"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/zitadel/zitadel/backend/v3/domain"
|
|
"github.com/zitadel/zitadel/backend/v3/storage/database"
|
|
"github.com/zitadel/zitadel/backend/v3/storage/database/repository"
|
|
)
|
|
|
|
func TestGetProject(t *testing.T) {
|
|
tx, rollback := transactionForRollback(t)
|
|
defer rollback()
|
|
instanceID := createInstance(t, tx)
|
|
orgID := createOrganization(t, tx, instanceID)
|
|
projectRepo := repository.ProjectRepository()
|
|
|
|
firstProject := &domain.Project{
|
|
InstanceID: instanceID,
|
|
OrganizationID: orgID,
|
|
ID: gofakeit.UUID(),
|
|
Name: gofakeit.Name(),
|
|
State: domain.ProjectStateActive,
|
|
ShouldAssertRole: true,
|
|
IsAuthorizationRequired: true,
|
|
IsProjectAccessRequired: true,
|
|
UsedLabelingSettingOwner: 1,
|
|
}
|
|
err := projectRepo.Create(t.Context(), tx, firstProject)
|
|
require.NoError(t, err)
|
|
secondProject := &domain.Project{
|
|
InstanceID: instanceID,
|
|
OrganizationID: orgID,
|
|
ID: gofakeit.UUID(),
|
|
Name: gofakeit.Name(),
|
|
State: domain.ProjectStateActive,
|
|
ShouldAssertRole: true,
|
|
IsAuthorizationRequired: true,
|
|
IsProjectAccessRequired: true,
|
|
UsedLabelingSettingOwner: 1,
|
|
}
|
|
err = projectRepo.Create(t.Context(), tx, secondProject)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
condition database.Condition
|
|
want *domain.Project
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "incomplete condition",
|
|
condition: projectRepo.IDCondition(firstProject.ID),
|
|
wantErr: database.NewMissingConditionError(projectRepo.IDColumn()),
|
|
},
|
|
{
|
|
name: "not found",
|
|
condition: projectRepo.PrimaryKeyCondition(instanceID, "nix"),
|
|
wantErr: database.NewNoRowFoundError(nil),
|
|
},
|
|
{
|
|
name: "too many",
|
|
condition: projectRepo.InstanceIDCondition(instanceID),
|
|
wantErr: database.NewMultipleRowsFoundError(nil),
|
|
},
|
|
{
|
|
name: "ok",
|
|
condition: projectRepo.PrimaryKeyCondition(instanceID, firstProject.ID),
|
|
want: firstProject,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := projectRepo.Get(t.Context(), tx, database.WithCondition(tt.condition))
|
|
require.ErrorIs(t, err, tt.wantErr)
|
|
assert.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListProjects(t *testing.T) {
|
|
tx, rollback := transactionForRollback(t)
|
|
defer rollback()
|
|
instanceID := createInstance(t, tx)
|
|
firstOrgID := createOrganization(t, tx, instanceID)
|
|
secondOrgID := createOrganization(t, tx, instanceID)
|
|
thirdOrgID := createOrganization(t, tx, instanceID)
|
|
grantedOrgID := createOrganization(t, tx, instanceID)
|
|
projectRepo := repository.ProjectRepository()
|
|
projectGrantRepo := repository.ProjectGrantRepository()
|
|
|
|
projects := [...]*domain.Project{
|
|
{
|
|
InstanceID: instanceID,
|
|
OrganizationID: firstOrgID,
|
|
ID: "1",
|
|
Name: "spanac",
|
|
State: domain.ProjectStateActive,
|
|
ShouldAssertRole: true,
|
|
IsAuthorizationRequired: true,
|
|
IsProjectAccessRequired: true,
|
|
UsedLabelingSettingOwner: 1,
|
|
},
|
|
{
|
|
InstanceID: instanceID,
|
|
OrganizationID: firstOrgID,
|
|
ID: "2",
|
|
Name: "foobar",
|
|
State: domain.ProjectStateInactive,
|
|
ShouldAssertRole: true,
|
|
IsAuthorizationRequired: true,
|
|
IsProjectAccessRequired: true,
|
|
UsedLabelingSettingOwner: 1,
|
|
},
|
|
{
|
|
InstanceID: instanceID,
|
|
OrganizationID: secondOrgID,
|
|
ID: "3",
|
|
Name: "foobaz",
|
|
State: domain.ProjectStateActive,
|
|
ShouldAssertRole: true,
|
|
IsAuthorizationRequired: true,
|
|
IsProjectAccessRequired: true,
|
|
UsedLabelingSettingOwner: 1,
|
|
},
|
|
{
|
|
InstanceID: instanceID,
|
|
OrganizationID: secondOrgID,
|
|
ID: "4",
|
|
Name: "bazqux",
|
|
State: domain.ProjectStateInactive,
|
|
ShouldAssertRole: true,
|
|
IsAuthorizationRequired: true,
|
|
IsProjectAccessRequired: true,
|
|
UsedLabelingSettingOwner: 1,
|
|
},
|
|
{
|
|
InstanceID: instanceID,
|
|
OrganizationID: thirdOrgID,
|
|
ID: "5",
|
|
Name: "granted",
|
|
State: domain.ProjectStateActive,
|
|
ShouldAssertRole: true,
|
|
IsAuthorizationRequired: true,
|
|
IsProjectAccessRequired: true,
|
|
UsedLabelingSettingOwner: 1,
|
|
},
|
|
{
|
|
InstanceID: instanceID,
|
|
OrganizationID: thirdOrgID,
|
|
ID: "5",
|
|
Name: "granted",
|
|
State: domain.ProjectStateActive,
|
|
ShouldAssertRole: true,
|
|
IsAuthorizationRequired: true,
|
|
IsProjectAccessRequired: true,
|
|
UsedLabelingSettingOwner: 1,
|
|
GrantID: gu.Ptr("1"),
|
|
GrantedOrganizationID: gu.Ptr(grantedOrgID),
|
|
GrantState: gu.Ptr(domain.ProjectGrantStateActive),
|
|
},
|
|
{
|
|
InstanceID: instanceID,
|
|
OrganizationID: grantedOrgID,
|
|
ID: "6",
|
|
Name: "owned",
|
|
State: domain.ProjectStateActive,
|
|
ShouldAssertRole: true,
|
|
IsAuthorizationRequired: true,
|
|
IsProjectAccessRequired: true,
|
|
UsedLabelingSettingOwner: 1,
|
|
},
|
|
}
|
|
for _, project := range projects {
|
|
// Granted Projects
|
|
if project.GrantedOrganizationID != nil {
|
|
err := projectGrantRepo.Create(t.Context(), tx, &domain.ProjectGrant{
|
|
InstanceID: project.InstanceID,
|
|
ID: *project.GrantID,
|
|
ProjectID: project.ID,
|
|
GrantedOrganizationID: *project.GrantedOrganizationID,
|
|
GrantingOrganizationID: project.OrganizationID,
|
|
State: domain.ProjectGrantStateActive,
|
|
})
|
|
require.NoError(t, err)
|
|
} else {
|
|
err := projectRepo.Create(t.Context(), tx, project)
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
condition database.Condition
|
|
want []*domain.Project
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "incomplete condition",
|
|
condition: projectRepo.OrganizationIDCondition(firstOrgID),
|
|
wantErr: database.NewMissingConditionError(projectRepo.IDColumn()),
|
|
},
|
|
{
|
|
name: "no results, ok",
|
|
condition: projectRepo.PrimaryKeyCondition(instanceID, "nix"),
|
|
},
|
|
{
|
|
name: "all from instance",
|
|
condition: projectRepo.InstanceIDCondition(instanceID),
|
|
want: projects[:],
|
|
},
|
|
{
|
|
name: "all from first org",
|
|
condition: database.And(
|
|
projectRepo.InstanceIDCondition(instanceID),
|
|
projectRepo.OrganizationIDCondition(firstOrgID),
|
|
),
|
|
want: projects[0:2],
|
|
},
|
|
{
|
|
name: "name starts with 'foo'",
|
|
condition: database.And(
|
|
projectRepo.InstanceIDCondition(instanceID),
|
|
projectRepo.NameCondition(database.TextOperationStartsWith, "foo"),
|
|
),
|
|
want: projects[1:3],
|
|
},
|
|
{
|
|
name: "state active from first or second org",
|
|
condition: database.And(
|
|
projectRepo.InstanceIDCondition(instanceID),
|
|
database.Or(
|
|
projectRepo.OrganizationIDCondition(firstOrgID),
|
|
projectRepo.OrganizationIDCondition(secondOrgID),
|
|
),
|
|
projectRepo.StateCondition(domain.ProjectStateActive),
|
|
),
|
|
want: []*domain.Project{projects[0], projects[2]},
|
|
},
|
|
{
|
|
name: "all from granted org or granted to granted org",
|
|
condition: database.And(
|
|
projectRepo.InstanceIDCondition(instanceID),
|
|
database.Or(
|
|
projectRepo.OrganizationIDCondition(grantedOrgID),
|
|
projectRepo.GrantedOrganizationIDCondition(grantedOrgID),
|
|
),
|
|
),
|
|
want: []*domain.Project{projects[5], projects[6]},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := projectRepo.List(t.Context(), tx,
|
|
database.WithCondition(tt.condition),
|
|
database.WithOrderByAscending(projectRepo.PrimaryKeyColumns()...),
|
|
)
|
|
require.ErrorIs(t, err, tt.wantErr)
|
|
assert.EqualExportedValues(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateProject(t *testing.T) {
|
|
tx, rollback := transactionForRollback(t)
|
|
defer rollback()
|
|
instanceID := createInstance(t, tx)
|
|
orgID := createOrganization(t, tx, instanceID)
|
|
projectRepo := repository.ProjectRepository()
|
|
|
|
existingProject := &domain.Project{
|
|
InstanceID: instanceID,
|
|
OrganizationID: orgID,
|
|
ID: gofakeit.UUID(),
|
|
Name: gofakeit.Name(),
|
|
State: domain.ProjectStateActive,
|
|
ShouldAssertRole: true,
|
|
IsAuthorizationRequired: true,
|
|
IsProjectAccessRequired: true,
|
|
UsedLabelingSettingOwner: 1,
|
|
}
|
|
err := projectRepo.Create(t.Context(), tx, existingProject)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
project *domain.Project
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "add project",
|
|
project: &domain.Project{
|
|
InstanceID: instanceID,
|
|
OrganizationID: orgID,
|
|
ID: gofakeit.UUID(),
|
|
Name: gofakeit.Name(),
|
|
State: domain.ProjectStateActive,
|
|
ShouldAssertRole: true,
|
|
IsAuthorizationRequired: true,
|
|
IsProjectAccessRequired: true,
|
|
UsedLabelingSettingOwner: 1,
|
|
},
|
|
},
|
|
{
|
|
name: "non-existing instance",
|
|
project: &domain.Project{
|
|
InstanceID: "foo",
|
|
OrganizationID: orgID,
|
|
ID: gofakeit.UUID(),
|
|
Name: gofakeit.Name(),
|
|
State: domain.ProjectStateActive,
|
|
ShouldAssertRole: true,
|
|
IsAuthorizationRequired: true,
|
|
IsProjectAccessRequired: true,
|
|
UsedLabelingSettingOwner: 1,
|
|
},
|
|
wantErr: new(database.ForeignKeyError),
|
|
},
|
|
{
|
|
name: "non-existing org",
|
|
project: &domain.Project{
|
|
InstanceID: instanceID,
|
|
OrganizationID: "foo",
|
|
ID: gofakeit.UUID(),
|
|
Name: gofakeit.Name(),
|
|
State: domain.ProjectStateActive,
|
|
ShouldAssertRole: true,
|
|
IsAuthorizationRequired: true,
|
|
IsProjectAccessRequired: true,
|
|
UsedLabelingSettingOwner: 1,
|
|
},
|
|
wantErr: new(database.ForeignKeyError),
|
|
},
|
|
{
|
|
name: "empty id error",
|
|
project: &domain.Project{
|
|
InstanceID: instanceID,
|
|
OrganizationID: orgID,
|
|
ID: "",
|
|
Name: gofakeit.Name(),
|
|
State: domain.ProjectStateActive,
|
|
ShouldAssertRole: true,
|
|
IsAuthorizationRequired: true,
|
|
IsProjectAccessRequired: true,
|
|
UsedLabelingSettingOwner: 1,
|
|
},
|
|
wantErr: new(database.CheckError),
|
|
},
|
|
{
|
|
name: "empty name error",
|
|
project: &domain.Project{
|
|
InstanceID: instanceID,
|
|
OrganizationID: orgID,
|
|
ID: gofakeit.UUID(),
|
|
Name: "",
|
|
State: domain.ProjectStateActive,
|
|
ShouldAssertRole: true,
|
|
IsAuthorizationRequired: true,
|
|
IsProjectAccessRequired: true,
|
|
UsedLabelingSettingOwner: 1,
|
|
},
|
|
wantErr: new(database.CheckError),
|
|
},
|
|
{
|
|
name: "duplicate project",
|
|
project: existingProject,
|
|
wantErr: new(database.UniqueError),
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
savepoint, rollback := savepointForRollback(t, tx)
|
|
defer rollback()
|
|
err := projectRepo.Create(t.Context(), savepoint, tt.project)
|
|
require.ErrorIs(t, err, tt.wantErr)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUpdateProject(t *testing.T) {
|
|
tx, rollback := transactionForRollback(t)
|
|
defer rollback()
|
|
instanceID := createInstance(t, tx)
|
|
orgID := createOrganization(t, tx, instanceID)
|
|
projectRepo := repository.ProjectRepository()
|
|
|
|
existingProject := &domain.Project{
|
|
InstanceID: instanceID,
|
|
OrganizationID: orgID,
|
|
ID: gofakeit.UUID(),
|
|
Name: gofakeit.Name(),
|
|
State: domain.ProjectStateActive,
|
|
ShouldAssertRole: true,
|
|
IsAuthorizationRequired: true,
|
|
IsProjectAccessRequired: true,
|
|
UsedLabelingSettingOwner: 1,
|
|
}
|
|
err := projectRepo.Create(t.Context(), tx, existingProject)
|
|
require.NoError(t, err)
|
|
lastUpdatedAt := existingProject.UpdatedAt
|
|
|
|
tests := []struct {
|
|
name string
|
|
condition database.Condition
|
|
changes []database.Change
|
|
wantRowsAffected int64
|
|
wantErr error
|
|
assertChanges func(t *testing.T, project *domain.Project)
|
|
}{
|
|
{
|
|
name: "no changes",
|
|
condition: projectRepo.PrimaryKeyCondition(instanceID, existingProject.ID),
|
|
changes: []database.Change{},
|
|
wantRowsAffected: 0,
|
|
wantErr: database.ErrNoChanges,
|
|
},
|
|
{
|
|
name: "incomplete condition",
|
|
condition: projectRepo.InstanceIDCondition(instanceID),
|
|
changes: []database.Change{
|
|
projectRepo.SetName("new name"),
|
|
},
|
|
wantRowsAffected: 0,
|
|
wantErr: database.NewMissingConditionError(projectRepo.IDColumn()),
|
|
},
|
|
{
|
|
name: "set name",
|
|
condition: projectRepo.PrimaryKeyCondition(instanceID, existingProject.ID),
|
|
changes: []database.Change{
|
|
projectRepo.SetName("new name"),
|
|
},
|
|
wantRowsAffected: 1,
|
|
assertChanges: func(t *testing.T, updatedProject *domain.Project) {
|
|
assert.Equal(t, "new name", updatedProject.Name)
|
|
},
|
|
},
|
|
{
|
|
name: "set state",
|
|
condition: projectRepo.PrimaryKeyCondition(instanceID, existingProject.ID),
|
|
changes: []database.Change{
|
|
projectRepo.SetState(domain.ProjectStateInactive),
|
|
},
|
|
wantRowsAffected: 1,
|
|
assertChanges: func(t *testing.T, updatedProject *domain.Project) {
|
|
assert.Equal(t, domain.ProjectStateInactive, updatedProject.State)
|
|
},
|
|
},
|
|
{
|
|
name: "set should_assert_role",
|
|
condition: projectRepo.PrimaryKeyCondition(instanceID, existingProject.ID),
|
|
changes: []database.Change{
|
|
projectRepo.SetShouldAssertRole(false),
|
|
},
|
|
wantRowsAffected: 1,
|
|
assertChanges: func(t *testing.T, updatedProject *domain.Project) {
|
|
assert.False(t, updatedProject.ShouldAssertRole)
|
|
},
|
|
},
|
|
{
|
|
name: "set is_authorization_required",
|
|
condition: projectRepo.PrimaryKeyCondition(instanceID, existingProject.ID),
|
|
changes: []database.Change{
|
|
projectRepo.SetIsAuthorizationRequired(false),
|
|
},
|
|
wantRowsAffected: 1,
|
|
assertChanges: func(t *testing.T, updatedProject *domain.Project) {
|
|
assert.False(t, updatedProject.IsAuthorizationRequired)
|
|
},
|
|
},
|
|
{
|
|
name: "set is_project_access_required",
|
|
condition: projectRepo.PrimaryKeyCondition(instanceID, existingProject.ID),
|
|
changes: []database.Change{
|
|
projectRepo.SetIsProjectAccessRequired(false),
|
|
},
|
|
wantRowsAffected: 1,
|
|
assertChanges: func(t *testing.T, updatedProject *domain.Project) {
|
|
assert.False(t, updatedProject.IsProjectAccessRequired)
|
|
},
|
|
},
|
|
{
|
|
name: "set used_labeling_setting_owner",
|
|
condition: projectRepo.PrimaryKeyCondition(instanceID, existingProject.ID),
|
|
changes: []database.Change{
|
|
projectRepo.SetUsedLabelingSettingOwner(2),
|
|
},
|
|
wantRowsAffected: 1,
|
|
assertChanges: func(t *testing.T, updatedProject *domain.Project) {
|
|
assert.Equal(t, int16(2), updatedProject.UsedLabelingSettingOwner)
|
|
},
|
|
},
|
|
{
|
|
name: "set empty name, not allowed",
|
|
condition: projectRepo.PrimaryKeyCondition(instanceID, existingProject.ID),
|
|
changes: []database.Change{
|
|
projectRepo.SetName(""),
|
|
},
|
|
wantRowsAffected: 0,
|
|
wantErr: new(database.CheckError),
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
savepoint, rollback := savepointForRollback(t, tx)
|
|
defer rollback()
|
|
rowsAffected, err := projectRepo.Update(t.Context(), savepoint, tt.condition, tt.changes...)
|
|
require.ErrorIs(t, err, tt.wantErr)
|
|
assert.Equal(t, tt.wantRowsAffected, rowsAffected)
|
|
|
|
if tt.assertChanges != nil {
|
|
updatedProject, err := projectRepo.Get(t.Context(), savepoint, database.WithCondition(
|
|
projectRepo.PrimaryKeyCondition(instanceID, existingProject.ID),
|
|
))
|
|
require.NoError(t, err)
|
|
assert.WithinRange(t, updatedProject.CreatedAt, existingProject.CreatedAt, existingProject.CreatedAt)
|
|
assert.WithinRange(t, updatedProject.UpdatedAt, lastUpdatedAt, lastUpdatedAt.Add(time.Second))
|
|
lastUpdatedAt = updatedProject.UpdatedAt
|
|
tt.assertChanges(t, updatedProject)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDeleteProject(t *testing.T) {
|
|
tx, rollback := transactionForRollback(t)
|
|
defer rollback()
|
|
instanceID := createInstance(t, tx)
|
|
orgID := createOrganization(t, tx, instanceID)
|
|
projectRepo := repository.ProjectRepository()
|
|
|
|
existingProject := &domain.Project{
|
|
InstanceID: instanceID,
|
|
OrganizationID: orgID,
|
|
ID: gofakeit.UUID(),
|
|
Name: gofakeit.Name(),
|
|
State: domain.ProjectStateActive,
|
|
ShouldAssertRole: true,
|
|
IsAuthorizationRequired: true,
|
|
IsProjectAccessRequired: true,
|
|
UsedLabelingSettingOwner: 1,
|
|
}
|
|
err := projectRepo.Create(t.Context(), tx, existingProject)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
condition database.Condition
|
|
wantRowsAffected int64
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "incomplete condition",
|
|
condition: projectRepo.InstanceIDCondition(instanceID),
|
|
wantRowsAffected: 0,
|
|
wantErr: database.NewMissingConditionError(projectRepo.IDColumn()),
|
|
},
|
|
{
|
|
name: "not found",
|
|
condition: projectRepo.PrimaryKeyCondition(instanceID, "foo"),
|
|
wantRowsAffected: 0,
|
|
},
|
|
{
|
|
name: "delete project",
|
|
condition: projectRepo.PrimaryKeyCondition(instanceID, existingProject.ID),
|
|
wantRowsAffected: 1,
|
|
},
|
|
{
|
|
name: "delete project twice",
|
|
condition: projectRepo.PrimaryKeyCondition(instanceID, existingProject.ID),
|
|
wantRowsAffected: 0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
rowsAffected, err := projectRepo.Delete(t.Context(), tx, tt.condition)
|
|
require.ErrorIs(t, err, tt.wantErr)
|
|
assert.Equal(t, tt.wantRowsAffected, rowsAffected)
|
|
})
|
|
}
|
|
}
|