Replace the separate Add Source operation with multi-select source fields on create and update. Create passes accessReviewSourceIds to the existing GraphQL input; update gains the same omittable field and backend source sync so workflows can configure sources in one step. Load source options from the organization in the n8n UI, and keep surfacing INVALID errors when start fails for missing sources. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
1603 lines
41 KiB
Go
1603 lines
41 KiB
Go
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// SOFTWARE.
|
|
|
|
package console_test
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.probo.inc/probo/e2e/internal/factory"
|
|
"go.probo.inc/probo/e2e/internal/testutil"
|
|
)
|
|
|
|
const testCsvData = "email,full_name,role,job_title,is_admin,active,mfa_status,auth_method,last_login,account_created_at,external_id\njane@example.com,Jane Smith,admin,CTO,true,true,ENABLED,SSO,2026-01-15T00:00:00Z,2024-06-01T00:00:00Z,ext-jane"
|
|
|
|
func TestAccessReviewSource_Create(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
|
|
t.Run("with name only", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const query = `
|
|
mutation($input: CreateAccessReviewSourceInput!) {
|
|
createAccessReviewSource(input: $input) {
|
|
accessReviewSourceEdge {
|
|
node {
|
|
id
|
|
name
|
|
createdAt
|
|
updatedAt
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
CreateAccessReviewSource struct {
|
|
AccessReviewSourceEdge struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
} `json:"node"`
|
|
} `json:"accessReviewSourceEdge"`
|
|
} `json:"createAccessReviewSource"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": orgID,
|
|
"name": "Slack",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
node := result.CreateAccessReviewSource.AccessReviewSourceEdge.Node
|
|
assert.NotEmpty(t, node.ID)
|
|
assert.Equal(t, "Slack", node.Name)
|
|
assert.NotEmpty(t, node.CreatedAt)
|
|
})
|
|
|
|
t.Run("with csv data", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const query = `
|
|
mutation($input: CreateAccessReviewSourceInput!) {
|
|
createAccessReviewSource(input: $input) {
|
|
accessReviewSourceEdge {
|
|
node {
|
|
id
|
|
name
|
|
csvData
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
CreateAccessReviewSource struct {
|
|
AccessReviewSourceEdge struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
CsvData *string `json:"csvData"`
|
|
} `json:"node"`
|
|
} `json:"accessReviewSourceEdge"`
|
|
} `json:"createAccessReviewSource"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": orgID,
|
|
"name": "CSV Import",
|
|
"csvData": testCsvData,
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
node := result.CreateAccessReviewSource.AccessReviewSourceEdge.Node
|
|
assert.NotEmpty(t, node.ID)
|
|
assert.Equal(t, "CSV Import", node.Name)
|
|
require.NotNil(t, node.CsvData)
|
|
assert.Contains(t, *node.CsvData, "jane@example.com")
|
|
})
|
|
}
|
|
|
|
func TestAccessReviewSource_Update(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
sourceID := factory.NewAccessReviewSource(owner, orgID).
|
|
WithName("Original Source").
|
|
Create()
|
|
|
|
const query = `
|
|
mutation($input: UpdateAccessReviewSourceInput!) {
|
|
updateAccessReviewSource(input: $input) {
|
|
accessReviewSource {
|
|
id
|
|
name
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
UpdateAccessReviewSource struct {
|
|
AccessReviewSource struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
} `json:"accessReviewSource"`
|
|
} `json:"updateAccessReviewSource"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"accessReviewSourceId": sourceID,
|
|
"name": "Updated Source",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, sourceID, result.UpdateAccessReviewSource.AccessReviewSource.ID)
|
|
assert.Equal(t, "Updated Source", result.UpdateAccessReviewSource.AccessReviewSource.Name)
|
|
}
|
|
|
|
func TestAccessReviewSource_Delete(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
sourceID := factory.NewAccessReviewSource(owner, orgID).
|
|
WithName("Source to Delete").
|
|
Create()
|
|
|
|
const query = `
|
|
mutation($input: DeleteAccessReviewSourceInput!) {
|
|
deleteAccessReviewSource(input: $input) {
|
|
deletedAccessReviewSourceId
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
DeleteAccessReviewSource struct {
|
|
DeletedAccessReviewSourceID string `json:"deletedAccessReviewSourceId"`
|
|
} `json:"deleteAccessReviewSource"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"accessReviewSourceId": sourceID,
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, sourceID, result.DeleteAccessReviewSource.DeletedAccessReviewSourceID)
|
|
}
|
|
|
|
func TestAccessReviewSource_List(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
|
|
for _, name := range []string{"Slack", "GitHub", "Google Workspace"} {
|
|
factory.NewAccessReviewSource(owner, orgID).WithName(name).Create()
|
|
}
|
|
|
|
const query = `
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Organization {
|
|
accessReviewSources(first: 10) {
|
|
edges {
|
|
node {
|
|
id
|
|
name
|
|
}
|
|
}
|
|
totalCount
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
Node struct {
|
|
AccessReviewSources struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
TotalCount int `json:"totalCount"`
|
|
} `json:"accessReviewSources"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{"id": orgID}, &result)
|
|
require.NoError(t, err)
|
|
assert.GreaterOrEqual(t, result.Node.AccessReviewSources.TotalCount, 3)
|
|
}
|
|
|
|
func TestAccessReviewCampaign_Create(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
|
|
t.Run("with name only", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const query = `
|
|
mutation($input: CreateAccessReviewCampaignInput!) {
|
|
createAccessReviewCampaign(input: $input) {
|
|
accessReviewCampaignEdge {
|
|
node {
|
|
id
|
|
name
|
|
status
|
|
createdAt
|
|
updatedAt
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
CreateAccessReviewCampaign struct {
|
|
AccessReviewCampaignEdge struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
} `json:"node"`
|
|
} `json:"accessReviewCampaignEdge"`
|
|
} `json:"createAccessReviewCampaign"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": orgID,
|
|
"name": "Q1 2026 Review",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
node := result.CreateAccessReviewCampaign.AccessReviewCampaignEdge.Node
|
|
assert.NotEmpty(t, node.ID)
|
|
assert.Equal(t, "Q1 2026 Review", node.Name)
|
|
assert.Equal(t, "DRAFT", node.Status)
|
|
assert.NotEmpty(t, node.CreatedAt)
|
|
})
|
|
|
|
t.Run("with access sources", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
source1ID := factory.NewAccessReviewSource(owner, orgID).
|
|
WithName("Slack Source").
|
|
Create()
|
|
source2ID := factory.NewAccessReviewSource(owner, orgID).
|
|
WithName("GitHub Source").
|
|
Create()
|
|
|
|
const query = `
|
|
mutation($input: CreateAccessReviewCampaignInput!) {
|
|
createAccessReviewCampaign(input: $input) {
|
|
accessReviewCampaignEdge {
|
|
node {
|
|
id
|
|
name
|
|
sources {
|
|
id
|
|
name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
CreateAccessReviewCampaign struct {
|
|
AccessReviewCampaignEdge struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
CampaignSources []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
} `json:"sources"`
|
|
} `json:"node"`
|
|
} `json:"accessReviewCampaignEdge"`
|
|
} `json:"createAccessReviewCampaign"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": orgID,
|
|
"name": "Campaign with Sources",
|
|
"accessReviewSourceIds": []string{source1ID, source2ID},
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
node := result.CreateAccessReviewCampaign.AccessReviewCampaignEdge.Node
|
|
assert.NotEmpty(t, node.ID)
|
|
assert.Equal(t, "Campaign with Sources", node.Name)
|
|
assert.Len(t, node.CampaignSources, 2)
|
|
})
|
|
}
|
|
|
|
func TestAccessReviewCampaign_Update(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
|
|
WithName("Original Campaign").
|
|
Create()
|
|
|
|
const query = `
|
|
mutation($input: UpdateAccessReviewCampaignInput!) {
|
|
updateAccessReviewCampaign(input: $input) {
|
|
accessReviewCampaign {
|
|
id
|
|
name
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
UpdateAccessReviewCampaign struct {
|
|
AccessReviewCampaign struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
} `json:"accessReviewCampaign"`
|
|
} `json:"updateAccessReviewCampaign"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"accessReviewCampaignId": campaignID,
|
|
"name": "Renamed Campaign",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, campaignID, result.UpdateAccessReviewCampaign.AccessReviewCampaign.ID)
|
|
assert.Equal(t, "Renamed Campaign", result.UpdateAccessReviewCampaign.AccessReviewCampaign.Name)
|
|
}
|
|
|
|
func TestAccessReviewCampaign_UpdateSources(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
source1ID := factory.NewAccessReviewSource(owner, orgID).
|
|
WithName("Slack Source").
|
|
Create()
|
|
source2ID := factory.NewAccessReviewSource(owner, orgID).
|
|
WithName("GitHub Source").
|
|
Create()
|
|
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
|
|
WithName("Campaign Sources Update").
|
|
WithAccessReviewSourceIDs([]string{source1ID}).
|
|
Create()
|
|
|
|
const query = `
|
|
mutation($input: UpdateAccessReviewCampaignInput!) {
|
|
updateAccessReviewCampaign(input: $input) {
|
|
accessReviewCampaign {
|
|
id
|
|
sources {
|
|
sourceId
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
UpdateAccessReviewCampaign struct {
|
|
AccessReviewCampaign struct {
|
|
ID string `json:"id"`
|
|
Sources []struct {
|
|
SourceID *string `json:"sourceId"`
|
|
} `json:"sources"`
|
|
} `json:"accessReviewCampaign"`
|
|
} `json:"updateAccessReviewCampaign"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"accessReviewCampaignId": campaignID,
|
|
"accessReviewSourceIds": []string{source2ID},
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, campaignID, result.UpdateAccessReviewCampaign.AccessReviewCampaign.ID)
|
|
require.Len(t, result.UpdateAccessReviewCampaign.AccessReviewCampaign.Sources, 1)
|
|
require.NotNil(t, result.UpdateAccessReviewCampaign.AccessReviewCampaign.Sources[0].SourceID)
|
|
assert.Equal(t, source2ID, *result.UpdateAccessReviewCampaign.AccessReviewCampaign.Sources[0].SourceID)
|
|
}
|
|
|
|
func TestAccessReviewCampaign_Delete(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
|
|
WithName("Campaign to Delete").
|
|
Create()
|
|
|
|
const query = `
|
|
mutation($input: DeleteAccessReviewCampaignInput!) {
|
|
deleteAccessReviewCampaign(input: $input) {
|
|
deletedAccessReviewCampaignId
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
DeleteAccessReviewCampaign struct {
|
|
DeletedAccessReviewCampaignID string `json:"deletedAccessReviewCampaignId"`
|
|
} `json:"deleteAccessReviewCampaign"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"accessReviewCampaignId": campaignID,
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, campaignID, result.DeleteAccessReviewCampaign.DeletedAccessReviewCampaignID)
|
|
}
|
|
|
|
// TestAccessReviewCampaign_DeleteRemovesFromListAndNode guards the contract
|
|
// the console frontend relies on after deleting a campaign: the campaign must
|
|
// disappear from the organization's `accessReviewCampaigns` connection, and a
|
|
// `node(id:)` lookup on the deleted GID must surface a NOT_FOUND error rather
|
|
// than partial data. Without this contract the cached Relay query in the
|
|
// access-reviews tab would render edges pointing to a vanished record and
|
|
// crash with "Unexpected error :(".
|
|
func TestAccessReviewCampaign_DeleteRemovesFromListAndNode(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
|
|
sourceID := factory.NewAccessReviewSource(owner, orgID).
|
|
WithName("Source for Delete").
|
|
WithCsvData(testCsvData).
|
|
Create()
|
|
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
|
|
WithName("Campaign to Cascade Delete").
|
|
WithAccessReviewSourceIDs([]string{sourceID}).
|
|
Create()
|
|
|
|
const deleteMutation = `
|
|
mutation($input: DeleteAccessReviewCampaignInput!) {
|
|
deleteAccessReviewCampaign(input: $input) {
|
|
deletedAccessReviewCampaignId
|
|
}
|
|
}
|
|
`
|
|
|
|
var deleteResult struct {
|
|
DeleteAccessReviewCampaign struct {
|
|
DeletedAccessReviewCampaignID string `json:"deletedAccessReviewCampaignId"`
|
|
} `json:"deleteAccessReviewCampaign"`
|
|
}
|
|
|
|
err := owner.Execute(deleteMutation, map[string]any{
|
|
"input": map[string]any{
|
|
"accessReviewCampaignId": campaignID,
|
|
},
|
|
}, &deleteResult)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, campaignID, deleteResult.DeleteAccessReviewCampaign.DeletedAccessReviewCampaignID)
|
|
|
|
// The campaign must no longer appear in the organization's campaign
|
|
// connection -- mirrors the AccessReviewCampaignsTabQuery the FE fires
|
|
// when the user navigates back to the access-reviews tab.
|
|
const listQuery = `
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Organization {
|
|
accessReviewCampaigns(first: 50) {
|
|
edges {
|
|
node { id }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var listResult struct {
|
|
Node struct {
|
|
AccessReviewCampaigns struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"accessReviewCampaigns"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err = owner.Execute(listQuery, map[string]any{"id": orgID}, &listResult)
|
|
require.NoError(t, err)
|
|
|
|
for _, edge := range listResult.Node.AccessReviewCampaigns.Edges {
|
|
assert.NotEqual(t, campaignID, edge.Node.ID, "deleted campaign must not appear in the connection")
|
|
}
|
|
|
|
// Resolving the deleted GID via `node(id:)` must error with NOT_FOUND so
|
|
// the cached Relay store can't keep serving a tombstoned record.
|
|
const nodeQuery = `
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on AccessReviewCampaign {
|
|
id
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
_, err = owner.Do(nodeQuery, map[string]any{"id": campaignID})
|
|
|
|
var gqlErrors testutil.GraphQLErrors
|
|
require.ErrorAs(t, err, &gqlErrors)
|
|
require.Len(t, gqlErrors, 1)
|
|
assert.Equal(t, "NOT_FOUND", gqlErrors[0].Code())
|
|
}
|
|
|
|
func TestAccessReviewCampaign_List(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
|
|
for _, name := range []string{"Q1 Review", "Q2 Review", "Q3 Review"} {
|
|
factory.NewAccessReviewCampaign(owner, orgID).WithName(name).Create()
|
|
}
|
|
|
|
const query = `
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Organization {
|
|
accessReviewCampaigns(first: 10) {
|
|
edges {
|
|
node {
|
|
id
|
|
name
|
|
status
|
|
}
|
|
}
|
|
totalCount
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
Node struct {
|
|
AccessReviewCampaigns struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
TotalCount int `json:"totalCount"`
|
|
} `json:"accessReviewCampaigns"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{"id": orgID}, &result)
|
|
require.NoError(t, err)
|
|
assert.GreaterOrEqual(t, result.Node.AccessReviewCampaigns.TotalCount, 3)
|
|
|
|
for _, edge := range result.Node.AccessReviewCampaigns.Edges {
|
|
assert.Equal(t, "DRAFT", edge.Node.Status)
|
|
}
|
|
}
|
|
|
|
func TestAccessReviewCampaign_NodeQuery(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
|
|
WithName("Node Query Campaign").
|
|
Create()
|
|
|
|
const query = `
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on AccessReviewCampaign {
|
|
id
|
|
name
|
|
status
|
|
organization {
|
|
id
|
|
}
|
|
statistics {
|
|
totalCount
|
|
}
|
|
createdAt
|
|
updatedAt
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Organization struct {
|
|
ID string `json:"id"`
|
|
} `json:"organization"`
|
|
Statistics struct {
|
|
TotalCount int `json:"totalCount"`
|
|
} `json:"statistics"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{"id": campaignID}, &result)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, campaignID, result.Node.ID)
|
|
assert.Equal(t, "Node Query Campaign", result.Node.Name)
|
|
assert.Equal(t, "DRAFT", result.Node.Status)
|
|
assert.Equal(t, orgID, result.Node.Organization.ID)
|
|
assert.Equal(t, 0, result.Node.Statistics.TotalCount)
|
|
}
|
|
|
|
func TestAccessReviewCampaign_StartWithCsvSource(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
|
|
sourceID := factory.NewAccessReviewSource(owner, orgID).
|
|
WithName("CSV Test Source").
|
|
WithCsvData(testCsvData).
|
|
Create()
|
|
|
|
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
|
|
WithName("CSV Campaign").
|
|
WithAccessReviewSourceIDs([]string{sourceID}).
|
|
Create()
|
|
|
|
const query = `
|
|
mutation($input: StartAccessReviewCampaignInput!) {
|
|
startAccessReviewCampaign(input: $input) {
|
|
accessReviewCampaign {
|
|
id
|
|
status
|
|
startedAt
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
StartAccessReviewCampaign struct {
|
|
AccessReviewCampaign struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
StartedAt *string `json:"startedAt"`
|
|
} `json:"accessReviewCampaign"`
|
|
} `json:"startAccessReviewCampaign"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"accessReviewCampaignId": campaignID,
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
campaign := result.StartAccessReviewCampaign.AccessReviewCampaign
|
|
assert.Equal(t, campaignID, campaign.ID)
|
|
assert.Equal(t, "IN_PROGRESS", campaign.Status)
|
|
assert.NotNil(t, campaign.StartedAt)
|
|
}
|
|
|
|
func TestAccessReviewCampaign_AddAndRemoveCampaignSource(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
|
|
sourceID := factory.NewAccessReviewSource(owner, orgID).
|
|
WithName("Scope Source").
|
|
Create()
|
|
|
|
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
|
|
WithName("Scope Management Campaign").
|
|
Create()
|
|
|
|
t.Run("add scope source", func(t *testing.T) {
|
|
const query = `
|
|
mutation($input: AddAccessReviewCampaignSourceInput!) {
|
|
addAccessReviewCampaignSource(input: $input) {
|
|
accessReviewCampaign {
|
|
id
|
|
sources {
|
|
id
|
|
name
|
|
source {
|
|
id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
AddAccessReviewCampaignSource struct {
|
|
AccessReviewCampaign struct {
|
|
ID string `json:"id"`
|
|
CampaignSources []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Source *struct {
|
|
ID string `json:"id"`
|
|
} `json:"source"`
|
|
} `json:"sources"`
|
|
} `json:"accessReviewCampaign"`
|
|
} `json:"addAccessReviewCampaignSource"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"accessReviewCampaignId": campaignID,
|
|
"accessReviewSourceId": sourceID,
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
campaign := result.AddAccessReviewCampaignSource.AccessReviewCampaign
|
|
assert.Equal(t, campaignID, campaign.ID)
|
|
assert.Len(t, campaign.CampaignSources, 1)
|
|
// The scope source id is now a per-campaign snapshot id, distinct from
|
|
// the live source id, which is exposed via the source link.
|
|
assert.NotEqual(t, sourceID, campaign.CampaignSources[0].ID)
|
|
require.NotNil(t, campaign.CampaignSources[0].Source)
|
|
assert.Equal(t, sourceID, campaign.CampaignSources[0].Source.ID)
|
|
})
|
|
|
|
t.Run("remove scope source", func(t *testing.T) {
|
|
const query = `
|
|
mutation($input: RemoveAccessReviewCampaignSourceInput!) {
|
|
removeAccessReviewCampaignSource(input: $input) {
|
|
accessReviewCampaign {
|
|
id
|
|
sources {
|
|
id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
RemoveAccessReviewCampaignSource struct {
|
|
AccessReviewCampaign struct {
|
|
ID string `json:"id"`
|
|
CampaignSources []struct {
|
|
ID string `json:"id"`
|
|
} `json:"sources"`
|
|
} `json:"accessReviewCampaign"`
|
|
} `json:"removeAccessReviewCampaignSource"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"accessReviewCampaignId": campaignID,
|
|
"accessReviewSourceId": sourceID,
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
campaign := result.RemoveAccessReviewCampaignSource.AccessReviewCampaign
|
|
assert.Equal(t, campaignID, campaign.ID)
|
|
assert.Empty(t, campaign.CampaignSources)
|
|
})
|
|
}
|
|
|
|
func TestAccessReviewCampaignSource_Node(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
|
|
sourceID := factory.NewAccessReviewSource(owner, orgID).
|
|
WithName("Node Query Source").
|
|
WithCsvData(testCsvData).
|
|
Create()
|
|
|
|
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
|
|
WithName("Node Query Campaign").
|
|
WithAccessReviewSourceIDs([]string{sourceID}).
|
|
Create()
|
|
|
|
const campaignQuery = `
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on AccessReviewCampaign {
|
|
sources {
|
|
id
|
|
name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var campaignResult struct {
|
|
Node struct {
|
|
Sources []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
} `json:"sources"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := owner.Execute(campaignQuery, map[string]any{"id": campaignID}, &campaignResult)
|
|
require.NoError(t, err)
|
|
require.Len(t, campaignResult.Node.Sources, 1)
|
|
|
|
campaignSourceID := campaignResult.Node.Sources[0].ID
|
|
|
|
const startQuery = `
|
|
mutation($input: StartAccessReviewCampaignInput!) {
|
|
startAccessReviewCampaign(input: $input) {
|
|
accessReviewCampaign {
|
|
id
|
|
status
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
err = owner.Execute(startQuery, map[string]any{
|
|
"input": map[string]any{
|
|
"accessReviewCampaignId": campaignID,
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
const nodeQuery = `
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
__typename
|
|
... on AccessReviewCampaignSource {
|
|
id
|
|
campaign {
|
|
id
|
|
}
|
|
name
|
|
entries(first: 10) {
|
|
totalCount
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var nodeResult struct {
|
|
Node struct {
|
|
Typename string `json:"__typename"`
|
|
ID string `json:"id"`
|
|
Campaign struct {
|
|
ID string `json:"id"`
|
|
} `json:"campaign"`
|
|
Name string `json:"name"`
|
|
Entries struct {
|
|
TotalCount int `json:"totalCount"`
|
|
} `json:"entries"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
require.Eventually(t, func() bool {
|
|
err := owner.Execute(nodeQuery, map[string]any{"id": campaignSourceID}, &nodeResult)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
return nodeResult.Node.Entries.TotalCount > 0
|
|
}, 60*time.Second, 1*time.Second, "campaign source should have entries after start")
|
|
|
|
assert.Equal(t, "AccessReviewCampaignSource", nodeResult.Node.Typename)
|
|
assert.Equal(t, campaignSourceID, nodeResult.Node.ID)
|
|
assert.Equal(t, campaignID, nodeResult.Node.Campaign.ID)
|
|
assert.Equal(t, "Node Query Source", nodeResult.Node.Name)
|
|
assert.Greater(t, nodeResult.Node.Entries.TotalCount, 0)
|
|
}
|
|
|
|
func TestAccessReviewCampaignSource_NameSurvivesSourceDeletion(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
|
|
const snapshotName = "Archived Snapshot Source"
|
|
|
|
sourceID := factory.NewAccessReviewSource(owner, orgID).
|
|
WithName(snapshotName).
|
|
WithCsvData(testCsvData).
|
|
Create()
|
|
|
|
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
|
|
WithName("Archival Campaign").
|
|
WithAccessReviewSourceIDs([]string{sourceID}).
|
|
Create()
|
|
|
|
const sourcesQuery = `
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on AccessReviewCampaign {
|
|
sources {
|
|
id
|
|
name
|
|
sourceId
|
|
source {
|
|
id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var before struct {
|
|
Node struct {
|
|
Sources []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
SourceID *string `json:"sourceId"`
|
|
Source *struct {
|
|
ID string `json:"id"`
|
|
} `json:"source"`
|
|
} `json:"sources"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := owner.Execute(sourcesQuery, map[string]any{"id": campaignID}, &before)
|
|
require.NoError(t, err)
|
|
require.Len(t, before.Node.Sources, 1)
|
|
assert.Equal(t, snapshotName, before.Node.Sources[0].Name)
|
|
require.NotNil(t, before.Node.Sources[0].SourceID)
|
|
assert.Equal(t, sourceID, *before.Node.Sources[0].SourceID)
|
|
require.NotNil(t, before.Node.Sources[0].Source)
|
|
assert.Equal(t, sourceID, before.Node.Sources[0].Source.ID)
|
|
|
|
const deleteQuery = `
|
|
mutation($input: DeleteAccessReviewSourceInput!) {
|
|
deleteAccessReviewSource(input: $input) {
|
|
deletedAccessReviewSourceId
|
|
}
|
|
}
|
|
`
|
|
|
|
err = owner.Execute(deleteQuery, map[string]any{
|
|
"input": map[string]any{
|
|
"accessReviewSourceId": sourceID,
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
var after struct {
|
|
Node struct {
|
|
Sources []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
SourceID *string `json:"sourceId"`
|
|
Source *struct {
|
|
ID string `json:"id"`
|
|
} `json:"source"`
|
|
} `json:"sources"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err = owner.Execute(sourcesQuery, map[string]any{"id": campaignID}, &after)
|
|
require.NoError(t, err)
|
|
require.Len(t, after.Node.Sources, 1)
|
|
assert.Equal(t, before.Node.Sources[0].ID, after.Node.Sources[0].ID)
|
|
assert.Equal(t, snapshotName, after.Node.Sources[0].Name)
|
|
assert.Nil(t, after.Node.Sources[0].SourceID)
|
|
assert.Nil(t, after.Node.Sources[0].Source)
|
|
}
|
|
|
|
func TestAccessReviewCampaign_Cancel(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
|
|
sourceID := factory.NewAccessReviewSource(owner, orgID).
|
|
WithName("Cancel Test Source").
|
|
WithCsvData(testCsvData).
|
|
Create()
|
|
|
|
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
|
|
WithName("Campaign to Cancel").
|
|
WithAccessReviewSourceIDs([]string{sourceID}).
|
|
Create()
|
|
|
|
// Start the campaign first
|
|
const startQuery = `
|
|
mutation($input: StartAccessReviewCampaignInput!) {
|
|
startAccessReviewCampaign(input: $input) {
|
|
accessReviewCampaign { id status }
|
|
}
|
|
}
|
|
`
|
|
|
|
err := owner.Execute(startQuery, map[string]any{
|
|
"input": map[string]any{
|
|
"accessReviewCampaignId": campaignID,
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Cancel it
|
|
const cancelQuery = `
|
|
mutation($input: CancelAccessReviewCampaignInput!) {
|
|
cancelAccessReviewCampaign(input: $input) {
|
|
accessReviewCampaign {
|
|
id
|
|
status
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
CancelAccessReviewCampaign struct {
|
|
AccessReviewCampaign struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
} `json:"accessReviewCampaign"`
|
|
} `json:"cancelAccessReviewCampaign"`
|
|
}
|
|
|
|
err = owner.Execute(cancelQuery, map[string]any{
|
|
"input": map[string]any{
|
|
"accessReviewCampaignId": campaignID,
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, campaignID, result.CancelAccessReviewCampaign.AccessReviewCampaign.ID)
|
|
assert.Equal(t, "CANCELLED", result.CancelAccessReviewCampaign.AccessReviewCampaign.Status)
|
|
}
|
|
|
|
func TestAccessReviewCampaign_Description(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
|
|
t.Run("create with description", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const query = `
|
|
mutation($input: CreateAccessReviewCampaignInput!) {
|
|
createAccessReviewCampaign(input: $input) {
|
|
accessReviewCampaignEdge {
|
|
node {
|
|
id
|
|
name
|
|
description
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
CreateAccessReviewCampaign struct {
|
|
AccessReviewCampaignEdge struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
} `json:"node"`
|
|
} `json:"accessReviewCampaignEdge"`
|
|
} `json:"createAccessReviewCampaign"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": orgID,
|
|
"name": "Q1 Review with Desc",
|
|
"description": "Quarterly review of all SaaS access",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
node := result.CreateAccessReviewCampaign.AccessReviewCampaignEdge.Node
|
|
assert.Equal(t, "Q1 Review with Desc", node.Name)
|
|
assert.Equal(t, "Quarterly review of all SaaS access", node.Description)
|
|
})
|
|
|
|
t.Run("update description", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
|
|
WithName("Description Update Test").
|
|
Create()
|
|
|
|
const query = `
|
|
mutation($input: UpdateAccessReviewCampaignInput!) {
|
|
updateAccessReviewCampaign(input: $input) {
|
|
accessReviewCampaign {
|
|
id
|
|
description
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
UpdateAccessReviewCampaign struct {
|
|
AccessReviewCampaign struct {
|
|
ID string `json:"id"`
|
|
Description string `json:"description"`
|
|
} `json:"accessReviewCampaign"`
|
|
} `json:"updateAccessReviewCampaign"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"accessReviewCampaignId": campaignID,
|
|
"description": "Updated description",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, campaignID, result.UpdateAccessReviewCampaign.AccessReviewCampaign.ID)
|
|
assert.Equal(t, "Updated description", result.UpdateAccessReviewCampaign.AccessReviewCampaign.Description)
|
|
})
|
|
}
|
|
|
|
func TestAccessReviewCampaign_FullLifecycle(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
|
|
// Step 1: Create a CSV source with test data
|
|
sourceID := factory.NewAccessReviewSource(owner, orgID).
|
|
WithName("Lifecycle Test Source").
|
|
WithCsvData(testCsvData).
|
|
Create()
|
|
|
|
// Step 2: Create a campaign with a description and the source
|
|
const createQuery = `
|
|
mutation($input: CreateAccessReviewCampaignInput!) {
|
|
createAccessReviewCampaign(input: $input) {
|
|
accessReviewCampaignEdge {
|
|
node {
|
|
id
|
|
name
|
|
description
|
|
status
|
|
sources {
|
|
id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var createResult struct {
|
|
CreateAccessReviewCampaign struct {
|
|
AccessReviewCampaignEdge struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Status string `json:"status"`
|
|
CampaignSources []struct {
|
|
ID string `json:"id"`
|
|
} `json:"sources"`
|
|
} `json:"node"`
|
|
} `json:"accessReviewCampaignEdge"`
|
|
} `json:"createAccessReviewCampaign"`
|
|
}
|
|
|
|
err := owner.Execute(createQuery, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": orgID,
|
|
"name": "Full Lifecycle Campaign",
|
|
"description": "Testing the full lifecycle",
|
|
"accessReviewSourceIds": []string{sourceID},
|
|
},
|
|
}, &createResult)
|
|
require.NoError(t, err)
|
|
|
|
campaignNode := createResult.CreateAccessReviewCampaign.AccessReviewCampaignEdge.Node
|
|
campaignID := campaignNode.ID
|
|
assert.Equal(t, "DRAFT", campaignNode.Status)
|
|
assert.Equal(t, "Testing the full lifecycle", campaignNode.Description)
|
|
assert.Len(t, campaignNode.CampaignSources, 1)
|
|
|
|
// Step 3: Start the campaign (triggers worker to fetch CSV data)
|
|
const startQuery = `
|
|
mutation($input: StartAccessReviewCampaignInput!) {
|
|
startAccessReviewCampaign(input: $input) {
|
|
accessReviewCampaign {
|
|
id
|
|
status
|
|
startedAt
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var startResult struct {
|
|
StartAccessReviewCampaign struct {
|
|
AccessReviewCampaign struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
StartedAt *string `json:"startedAt"`
|
|
} `json:"accessReviewCampaign"`
|
|
} `json:"startAccessReviewCampaign"`
|
|
}
|
|
|
|
err = owner.Execute(startQuery, map[string]any{
|
|
"input": map[string]any{
|
|
"accessReviewCampaignId": campaignID,
|
|
},
|
|
}, &startResult)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "IN_PROGRESS", startResult.StartAccessReviewCampaign.AccessReviewCampaign.Status)
|
|
assert.NotNil(t, startResult.StartAccessReviewCampaign.AccessReviewCampaign.StartedAt)
|
|
|
|
// Step 4: Wait for the worker to process entries and move campaign to PENDING_ACTIONS.
|
|
// Poll the campaign status until it transitions.
|
|
const nodeQuery = `
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on AccessReviewCampaign {
|
|
id
|
|
status
|
|
entries(first: 100) {
|
|
edges {
|
|
node {
|
|
id
|
|
email
|
|
fullName
|
|
active
|
|
decision
|
|
}
|
|
}
|
|
totalCount
|
|
}
|
|
statistics {
|
|
totalCount
|
|
decisionCounts {
|
|
decision
|
|
count
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
type campaignQueryResult struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
Entries struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
FullName string `json:"fullName"`
|
|
Active *bool `json:"active"`
|
|
Decision string `json:"decision"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
TotalCount int `json:"totalCount"`
|
|
} `json:"entries"`
|
|
Statistics struct {
|
|
TotalCount int `json:"totalCount"`
|
|
DecisionCounts []struct {
|
|
Decision string `json:"decision"`
|
|
Count int `json:"count"`
|
|
} `json:"decisionCounts"`
|
|
} `json:"statistics"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
var campaignResult campaignQueryResult
|
|
|
|
require.Eventually(t, func() bool {
|
|
err := owner.Execute(nodeQuery, map[string]any{"id": campaignID}, &campaignResult)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
return campaignResult.Node.Status == "PENDING_ACTIONS"
|
|
}, 60*time.Second, 1*time.Second, "campaign should transition to PENDING_ACTIONS")
|
|
|
|
// Verify entries were created from CSV data
|
|
assert.GreaterOrEqual(t, campaignResult.Node.Entries.TotalCount, 1)
|
|
assert.Equal(t, campaignResult.Node.Entries.TotalCount, campaignResult.Node.Statistics.TotalCount)
|
|
|
|
// All entries should be PENDING
|
|
for _, edge := range campaignResult.Node.Entries.Edges {
|
|
assert.Equal(t, "PENDING", edge.Node.Decision)
|
|
|
|
if edge.Node.Email == "jane@example.com" {
|
|
require.NotNil(t, edge.Node.Active)
|
|
assert.True(t, *edge.Node.Active)
|
|
}
|
|
}
|
|
|
|
// Step 5: Record decisions on all entries
|
|
const recordDecisionQuery = `
|
|
mutation($input: RecordAccessReviewEntryDecisionInput!) {
|
|
recordAccessReviewEntryDecision(input: $input) {
|
|
accessReviewEntry {
|
|
id
|
|
decision
|
|
decidedAt
|
|
decisionHistory {
|
|
id
|
|
decision
|
|
decidedAt
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
for _, edge := range campaignResult.Node.Entries.Edges {
|
|
var decisionResult struct {
|
|
RecordAccessReviewEntryDecision struct {
|
|
AccessReviewEntry struct {
|
|
ID string `json:"id"`
|
|
Decision string `json:"decision"`
|
|
DecidedAt *string `json:"decidedAt"`
|
|
DecisionHistory []struct {
|
|
ID string `json:"id"`
|
|
Decision string `json:"decision"`
|
|
} `json:"decisionHistory"`
|
|
} `json:"accessReviewEntry"`
|
|
} `json:"recordAccessReviewEntryDecision"`
|
|
}
|
|
|
|
err = owner.Execute(recordDecisionQuery, map[string]any{
|
|
"input": map[string]any{
|
|
"accessReviewEntryId": edge.Node.ID,
|
|
"decision": "APPROVED",
|
|
},
|
|
}, &decisionResult)
|
|
require.NoError(t, err)
|
|
|
|
entry := decisionResult.RecordAccessReviewEntryDecision.AccessReviewEntry
|
|
assert.Equal(t, "APPROVED", entry.Decision)
|
|
assert.NotNil(t, entry.DecidedAt)
|
|
|
|
// Verify decision history was recorded
|
|
assert.Len(t, entry.DecisionHistory, 1)
|
|
assert.Equal(t, "APPROVED", entry.DecisionHistory[0].Decision)
|
|
}
|
|
|
|
// Step 6: Close the campaign
|
|
const closeQuery = `
|
|
mutation($input: CloseAccessReviewCampaignInput!) {
|
|
closeAccessReviewCampaign(input: $input) {
|
|
accessReviewCampaign {
|
|
id
|
|
status
|
|
completedAt
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var closeResult struct {
|
|
CloseAccessReviewCampaign struct {
|
|
AccessReviewCampaign struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
CompletedAt *string `json:"completedAt"`
|
|
} `json:"accessReviewCampaign"`
|
|
} `json:"closeAccessReviewCampaign"`
|
|
}
|
|
|
|
err = owner.Execute(closeQuery, map[string]any{
|
|
"input": map[string]any{
|
|
"accessReviewCampaignId": campaignID,
|
|
},
|
|
}, &closeResult)
|
|
require.NoError(t, err)
|
|
|
|
closedCampaign := closeResult.CloseAccessReviewCampaign.AccessReviewCampaign
|
|
assert.Equal(t, "COMPLETED", closedCampaign.Status)
|
|
assert.NotNil(t, closedCampaign.CompletedAt)
|
|
}
|
|
|
|
func TestAccessReviewCampaign_CloseRequiresAllDecisions(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
|
|
sourceID := factory.NewAccessReviewSource(owner, orgID).
|
|
WithName("Close Guard Source").
|
|
WithCsvData(testCsvData).
|
|
Create()
|
|
|
|
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
|
|
WithName("Close Guard Campaign").
|
|
WithAccessReviewSourceIDs([]string{sourceID}).
|
|
Create()
|
|
|
|
// Start the campaign
|
|
const startQuery = `
|
|
mutation($input: StartAccessReviewCampaignInput!) {
|
|
startAccessReviewCampaign(input: $input) {
|
|
accessReviewCampaign { id status }
|
|
}
|
|
}
|
|
`
|
|
|
|
err := owner.Execute(startQuery, map[string]any{
|
|
"input": map[string]any{
|
|
"accessReviewCampaignId": campaignID,
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Wait for PENDING_ACTIONS
|
|
const nodeQuery = `
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on AccessReviewCampaign { status }
|
|
}
|
|
}
|
|
`
|
|
|
|
require.Eventually(t, func() bool {
|
|
var r struct {
|
|
Node struct {
|
|
Status string `json:"status"`
|
|
} `json:"node"`
|
|
}
|
|
if err := owner.Execute(nodeQuery, map[string]any{"id": campaignID}, &r); err != nil {
|
|
return false
|
|
}
|
|
|
|
return r.Node.Status == "PENDING_ACTIONS"
|
|
}, 60*time.Second, 1*time.Second)
|
|
|
|
// Try to close without deciding — should fail
|
|
const closeQuery = `
|
|
mutation($input: CloseAccessReviewCampaignInput!) {
|
|
closeAccessReviewCampaign(input: $input) {
|
|
accessReviewCampaign { id status }
|
|
}
|
|
}
|
|
`
|
|
|
|
_, err = owner.Do(closeQuery, map[string]any{
|
|
"input": map[string]any{
|
|
"accessReviewCampaignId": campaignID,
|
|
},
|
|
})
|
|
require.Error(t, err, "closing a campaign with undecided entries should fail")
|
|
}
|
|
|
|
func TestAccessReviewCampaign_StartWithoutSourcesFails(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
|
|
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
|
|
WithName("No Sources Campaign").
|
|
Create()
|
|
|
|
const startQuery = `
|
|
mutation($input: StartAccessReviewCampaignInput!) {
|
|
startAccessReviewCampaign(input: $input) {
|
|
accessReviewCampaign { id status }
|
|
}
|
|
}
|
|
`
|
|
|
|
_, err := owner.Do(startQuery, map[string]any{
|
|
"input": map[string]any{
|
|
"accessReviewCampaignId": campaignID,
|
|
},
|
|
})
|
|
require.Error(t, err, "starting a campaign without sources should fail")
|
|
}
|
|
|
|
func TestAccessReview_TenantIsolation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
org1Owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
org1ID := org1Owner.GetOrganizationID().String()
|
|
|
|
t.Run("cannot create access source in another organization", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const query = `
|
|
mutation($input: CreateAccessReviewSourceInput!) {
|
|
createAccessReviewSource(input: $input) {
|
|
accessReviewSourceEdge {
|
|
node { id }
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
_, err := org2Owner.Do(query, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": org1ID,
|
|
"name": "Unauthorized Source",
|
|
},
|
|
})
|
|
require.Error(t, err, "Should not be able to create access source in another organization")
|
|
})
|
|
|
|
t.Run("cannot create campaign in another organization", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const query = `
|
|
mutation($input: CreateAccessReviewCampaignInput!) {
|
|
createAccessReviewCampaign(input: $input) {
|
|
accessReviewCampaignEdge {
|
|
node { id }
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
_, err := org2Owner.Do(query, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": org1ID,
|
|
"name": "Unauthorized Campaign",
|
|
},
|
|
})
|
|
require.Error(t, err, "Should not be able to create campaign in another organization")
|
|
})
|
|
}
|