The source headers, LICENSE files, and license metadata had drifted apart. Align the entire project to MIT: - Convert every source-file header to the MIT text across all comment styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including SPDX-License-Identifier tags - Set the root and cookie-banner LICENSE files to the MIT text with a "MIT License" title line - Switch the package.json license fields, Docker image label, and cookie-banner README to MIT - Update docs and the genmodels header generator accordingly - Normalize copyright lines to a single format (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the hello@getprobo.com and hello@probo.inc emails to hello@probo.com and the comma-separated years to a hyphenated range Genuine third-party references are intentionally left untouched: the Lucide icon attributions (Lucide is ISC) and the trivy dependency license allowlist. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
1550 lines
40 KiB
Go
1550 lines
40 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_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")
|
|
})
|
|
}
|