Classification now lives exclusively on DocumentVersion. The field is removed from the Document model, all SQL queries, GraphQL Document type, SignableDocument type, UpdateDocumentInput, and MCP Document schema. New documents still accept classification in CreateDocumentInput, applied to the first version. New drafts inherit classification from the previous version. PDF generation uses the version classification. The drawer allows editing classification on draft versions via the updateDocumentVersion mutation. Classification is read-only on published versions. Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
1349 lines
33 KiB
Go
1349 lines
33 KiB
Go
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
|
//
|
|
// Permission to use, copy, modify, and/or distribute this software for any
|
|
// purpose with or without fee is hereby granted, provided that the above
|
|
// copyright notice and this permission notice appear in all copies.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
// PERFORMANCE OF THIS SOFTWARE.
|
|
|
|
package console_test
|
|
|
|
import (
|
|
"fmt"
|
|
"maps"
|
|
"strings"
|
|
"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"
|
|
)
|
|
|
|
func TestDocument_Create(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
tests := []struct {
|
|
name string
|
|
input map[string]any
|
|
assertField string
|
|
assertValue string
|
|
}{
|
|
{
|
|
name: "with full details",
|
|
input: map[string]any{
|
|
"title": "Security Policy",
|
|
"content": "This is the security policy content.",
|
|
"documentType": "POLICY",
|
|
"classification": "INTERNAL",
|
|
},
|
|
assertField: "title",
|
|
assertValue: "Security Policy",
|
|
},
|
|
{
|
|
name: "with POLICY type",
|
|
input: map[string]any{
|
|
"title": "Policy Document",
|
|
"content": "Policy content",
|
|
"documentType": "POLICY",
|
|
"classification": "INTERNAL",
|
|
},
|
|
assertField: "documentType",
|
|
assertValue: "POLICY",
|
|
},
|
|
{
|
|
name: "with PROCEDURE type",
|
|
input: map[string]any{
|
|
"title": "Procedure Document",
|
|
"content": "Procedure content",
|
|
"documentType": "PROCEDURE",
|
|
"classification": "INTERNAL",
|
|
},
|
|
assertField: "documentType",
|
|
assertValue: "PROCEDURE",
|
|
},
|
|
{
|
|
name: "with GOVERNANCE type",
|
|
input: map[string]any{
|
|
"title": "Governance Document",
|
|
"content": "Governance content",
|
|
"documentType": "GOVERNANCE",
|
|
"classification": "INTERNAL",
|
|
},
|
|
assertField: "documentType",
|
|
assertValue: "GOVERNANCE",
|
|
},
|
|
{
|
|
name: "with OTHER type",
|
|
input: map[string]any{
|
|
"title": "Other Document",
|
|
"content": "Other content",
|
|
"documentType": "OTHER",
|
|
"classification": "INTERNAL",
|
|
},
|
|
assertField: "documentType",
|
|
assertValue: "OTHER",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
query := `
|
|
mutation CreateDocument($input: CreateDocumentInput!) {
|
|
createDocument(input: $input) {
|
|
documentEdge {
|
|
node {
|
|
id
|
|
title
|
|
documentType
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
input := map[string]any{
|
|
"organizationId": owner.GetOrganizationID().String(),
|
|
}
|
|
maps.Copy(input, tt.input)
|
|
|
|
var result struct {
|
|
CreateDocument struct {
|
|
DocumentEdge struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
DocumentType string `json:"documentType"`
|
|
} `json:"node"`
|
|
} `json:"documentEdge"`
|
|
} `json:"createDocument"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{"input": input}, &result)
|
|
require.NoError(t, err)
|
|
|
|
node := result.CreateDocument.DocumentEdge.Node
|
|
assert.NotEmpty(t, node.ID)
|
|
|
|
switch tt.assertField {
|
|
case "title":
|
|
assert.Equal(t, tt.assertValue, node.Title)
|
|
case "documentType":
|
|
assert.Equal(t, tt.assertValue, node.DocumentType)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDocument_Create_Validation(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
tests := []struct {
|
|
name string
|
|
input map[string]any
|
|
skipOrganization bool
|
|
wantErrorContains string
|
|
}{
|
|
{
|
|
name: "missing organizationId",
|
|
input: map[string]any{
|
|
"title": "Test Document",
|
|
"content": "Test content",
|
|
"documentType": "POLICY",
|
|
"classification": "INTERNAL",
|
|
},
|
|
skipOrganization: true,
|
|
wantErrorContains: "organizationId",
|
|
},
|
|
{
|
|
name: "title with HTML tags",
|
|
input: map[string]any{
|
|
"title": "<script>alert('xss')</script>",
|
|
"content": "Test content",
|
|
"documentType": "POLICY",
|
|
"classification": "INTERNAL",
|
|
},
|
|
wantErrorContains: "HTML",
|
|
},
|
|
{
|
|
name: "title with newline",
|
|
input: map[string]any{
|
|
"title": "Test\nDocument",
|
|
"content": "Test content",
|
|
"documentType": "POLICY",
|
|
"classification": "INTERNAL",
|
|
},
|
|
wantErrorContains: "newline",
|
|
},
|
|
{
|
|
name: "title with carriage return",
|
|
input: map[string]any{
|
|
"title": "Test\rDocument",
|
|
"content": "Test content",
|
|
"documentType": "POLICY",
|
|
"classification": "INTERNAL",
|
|
},
|
|
wantErrorContains: "carriage return",
|
|
},
|
|
{
|
|
name: "title with null byte",
|
|
input: map[string]any{
|
|
"title": "Test\x00Document",
|
|
"content": "Test content",
|
|
"documentType": "POLICY",
|
|
"classification": "INTERNAL",
|
|
},
|
|
wantErrorContains: "control character",
|
|
},
|
|
{
|
|
name: "title with tab character",
|
|
input: map[string]any{
|
|
"title": "Test\tDocument",
|
|
"content": "Test content",
|
|
"documentType": "POLICY",
|
|
"classification": "INTERNAL",
|
|
},
|
|
wantErrorContains: "control character",
|
|
},
|
|
{
|
|
name: "title with zero-width space",
|
|
input: map[string]any{
|
|
"title": "Test\u200BDocument",
|
|
"content": "Test content",
|
|
"documentType": "POLICY",
|
|
"classification": "INTERNAL",
|
|
},
|
|
wantErrorContains: "zero-width",
|
|
},
|
|
{
|
|
name: "title with zero-width joiner",
|
|
input: map[string]any{
|
|
"title": "Test\u200DDocument",
|
|
"content": "Test content",
|
|
"documentType": "POLICY",
|
|
"classification": "INTERNAL",
|
|
},
|
|
wantErrorContains: "zero-width",
|
|
},
|
|
{
|
|
name: "title with right-to-left override",
|
|
input: map[string]any{
|
|
"title": "Test\u202EDocument",
|
|
"content": "Test content",
|
|
"documentType": "POLICY",
|
|
"classification": "INTERNAL",
|
|
},
|
|
wantErrorContains: "bidirectional",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
query := `
|
|
mutation CreateDocument($input: CreateDocumentInput!) {
|
|
createDocument(input: $input) {
|
|
documentEdge {
|
|
node {
|
|
id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
input := make(map[string]any)
|
|
if !tt.skipOrganization {
|
|
input["organizationId"] = owner.GetOrganizationID().String()
|
|
}
|
|
maps.Copy(input, tt.input)
|
|
|
|
_, err := owner.Do(query, map[string]any{"input": input})
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), tt.wantErrorContains)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDocument_Update(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
tests := []struct {
|
|
name string
|
|
setup func() string
|
|
input func(id string) map[string]any
|
|
assertField string
|
|
assertValue string
|
|
}{
|
|
{
|
|
name: "update title",
|
|
setup: func() string {
|
|
return factory.NewDocument(owner).
|
|
WithTitle("Document to Update").
|
|
Create()
|
|
},
|
|
input: func(id string) map[string]any {
|
|
return map[string]any{
|
|
"id": id,
|
|
"title": "Updated Document Title",
|
|
}
|
|
},
|
|
assertField: "title",
|
|
assertValue: "Updated Document Title",
|
|
},
|
|
{
|
|
name: "update document type",
|
|
setup: func() string {
|
|
return factory.NewDocument(owner).
|
|
WithTitle("Type Test").
|
|
WithDocumentType("POLICY").
|
|
Create()
|
|
},
|
|
input: func(id string) map[string]any {
|
|
return map[string]any{"id": id, "documentType": "PROCEDURE"}
|
|
},
|
|
assertField: "documentType",
|
|
assertValue: "PROCEDURE",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
documentID := tt.setup()
|
|
|
|
query := `
|
|
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
|
updateDocument(input: $input) {
|
|
document {
|
|
id
|
|
title
|
|
documentType
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
UpdateDocument struct {
|
|
Document struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
DocumentType string `json:"documentType"`
|
|
} `json:"document"`
|
|
} `json:"updateDocument"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{"input": tt.input(documentID)}, &result)
|
|
require.NoError(t, err)
|
|
|
|
doc := result.UpdateDocument.Document
|
|
switch tt.assertField {
|
|
case "title":
|
|
assert.Equal(t, tt.assertValue, doc.Title)
|
|
case "documentType":
|
|
assert.Equal(t, tt.assertValue, doc.DocumentType)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDocument_Update_Validation(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
baseDocumentID := factory.NewDocument(owner).WithTitle("Validation Test Document").Create()
|
|
|
|
tests := []struct {
|
|
name string
|
|
setup func() string
|
|
input func(id string) map[string]any
|
|
wantErrorContains string
|
|
}{
|
|
{
|
|
name: "invalid ID format",
|
|
setup: func() string { return "invalid-id-format" },
|
|
input: func(id string) map[string]any {
|
|
return map[string]any{"id": id, "title": "Test"}
|
|
},
|
|
wantErrorContains: "base64",
|
|
},
|
|
{
|
|
name: "title with HTML tags",
|
|
setup: func() string { return baseDocumentID },
|
|
input: func(id string) map[string]any {
|
|
return map[string]any{"id": id, "title": "<script>alert('xss')</script>"}
|
|
},
|
|
wantErrorContains: "HTML",
|
|
},
|
|
{
|
|
name: "title with newline",
|
|
setup: func() string { return baseDocumentID },
|
|
input: func(id string) map[string]any {
|
|
return map[string]any{"id": id, "title": "Test\nDocument"}
|
|
},
|
|
wantErrorContains: "newline",
|
|
},
|
|
{
|
|
name: "title with carriage return",
|
|
setup: func() string { return baseDocumentID },
|
|
input: func(id string) map[string]any {
|
|
return map[string]any{"id": id, "title": "Test\rDocument"}
|
|
},
|
|
wantErrorContains: "carriage return",
|
|
},
|
|
{
|
|
name: "title with null byte",
|
|
setup: func() string { return baseDocumentID },
|
|
input: func(id string) map[string]any {
|
|
return map[string]any{"id": id, "title": "Test\x00Document"}
|
|
},
|
|
wantErrorContains: "control character",
|
|
},
|
|
{
|
|
name: "title with zero-width space",
|
|
setup: func() string { return baseDocumentID },
|
|
input: func(id string) map[string]any {
|
|
return map[string]any{"id": id, "title": "Test\u200BDocument"}
|
|
},
|
|
wantErrorContains: "zero-width",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
documentID := tt.setup()
|
|
|
|
query := `
|
|
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
|
updateDocument(input: $input) {
|
|
document {
|
|
id
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
_, err := owner.Do(query, map[string]any{"input": tt.input(documentID)})
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), tt.wantErrorContains)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDocument_Delete(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
t.Run("delete existing document", func(t *testing.T) {
|
|
documentID := factory.NewDocument(owner).WithTitle("Document to Delete").Create()
|
|
|
|
query := `
|
|
mutation DeleteDocument($input: DeleteDocumentInput!) {
|
|
deleteDocument(input: $input) {
|
|
deletedDocumentId
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
DeleteDocument struct {
|
|
DeletedDocumentID string `json:"deletedDocumentId"`
|
|
} `json:"deleteDocument"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{"documentId": documentID},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, documentID, result.DeleteDocument.DeletedDocumentID)
|
|
})
|
|
}
|
|
|
|
func TestDocument_Delete_Validation(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
tests := []struct {
|
|
name string
|
|
documentID string
|
|
wantErrorContains string
|
|
}{
|
|
{
|
|
name: "invalid ID format",
|
|
documentID: "invalid-id-format",
|
|
wantErrorContains: "base64",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
query := `
|
|
mutation DeleteDocument($input: DeleteDocumentInput!) {
|
|
deleteDocument(input: $input) {
|
|
deletedDocumentId
|
|
}
|
|
}
|
|
`
|
|
|
|
_, err := owner.Do(query, map[string]any{
|
|
"input": map[string]any{"documentId": tt.documentID},
|
|
})
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), tt.wantErrorContains)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDocument_List(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
documentTitles := []string{"Document A", "Document B", "Document C"}
|
|
for _, title := range documentTitles {
|
|
factory.NewDocument(owner).WithTitle(title).Create()
|
|
}
|
|
|
|
query := `
|
|
query GetDocuments($id: ID!) {
|
|
node(id: $id) {
|
|
... on Organization {
|
|
documents(first: 10) {
|
|
edges {
|
|
node {
|
|
id
|
|
title
|
|
}
|
|
}
|
|
totalCount
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
Node struct {
|
|
Documents struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
TotalCount int `json:"totalCount"`
|
|
} `json:"documents"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"id": owner.GetOrganizationID().String(),
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
assert.GreaterOrEqual(t, result.Node.Documents.TotalCount, 3)
|
|
}
|
|
|
|
func TestDocument_Query(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
t.Run("query with non-existent ID returns error", func(t *testing.T) {
|
|
query := `
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Document {
|
|
id
|
|
title
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
err := owner.ExecuteShouldFail(query, map[string]any{
|
|
"id": "V0wtM0tMNmJBQ1lBQUFBQUFackhLSTJfbXJJRUFZVXo",
|
|
})
|
|
require.Error(t, err, "Non-existent ID should return error")
|
|
})
|
|
}
|
|
|
|
func TestDocument_Timestamps(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
t.Run("createdAt and updatedAt are set on create", func(t *testing.T) {
|
|
beforeCreate := time.Now().Add(-time.Second)
|
|
|
|
query := `
|
|
mutation CreateDocument($input: CreateDocumentInput!) {
|
|
createDocument(input: $input) {
|
|
documentEdge {
|
|
node {
|
|
id
|
|
createdAt
|
|
updatedAt
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
CreateDocument struct {
|
|
DocumentEdge struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
} `json:"node"`
|
|
} `json:"documentEdge"`
|
|
} `json:"createDocument"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": owner.GetOrganizationID().String(),
|
|
"title": "Timestamp Test Document",
|
|
"content": "Test content",
|
|
"documentType": "POLICY",
|
|
"classification": "INTERNAL",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
node := result.CreateDocument.DocumentEdge.Node
|
|
testutil.AssertTimestampsOnCreate(t, node.CreatedAt, node.UpdatedAt, beforeCreate)
|
|
})
|
|
|
|
t.Run("updatedAt changes on update", func(t *testing.T) {
|
|
documentID := factory.NewDocument(owner).WithTitle("Timestamp Update Test").Create()
|
|
|
|
getQuery := `
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Document {
|
|
createdAt
|
|
updatedAt
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var getResult struct {
|
|
Node struct {
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := owner.Execute(getQuery, map[string]any{"id": documentID}, &getResult)
|
|
require.NoError(t, err)
|
|
|
|
initialCreatedAt := getResult.Node.CreatedAt
|
|
initialUpdatedAt := getResult.Node.UpdatedAt
|
|
|
|
// Wait long enough for timestamp to change (database may have second precision)
|
|
time.Sleep(1100 * time.Millisecond)
|
|
|
|
updateQuery := `
|
|
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
|
updateDocument(input: $input) {
|
|
document {
|
|
createdAt
|
|
updatedAt
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var updateResult struct {
|
|
UpdateDocument struct {
|
|
Document struct {
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
} `json:"document"`
|
|
} `json:"updateDocument"`
|
|
}
|
|
|
|
err = owner.Execute(updateQuery, map[string]any{
|
|
"input": map[string]any{
|
|
"id": documentID,
|
|
"title": "Updated Timestamp Test",
|
|
},
|
|
}, &updateResult)
|
|
require.NoError(t, err)
|
|
|
|
doc := updateResult.UpdateDocument.Document
|
|
testutil.AssertTimestampsOnUpdate(t, doc.CreatedAt, doc.UpdatedAt, initialCreatedAt, initialUpdatedAt)
|
|
})
|
|
}
|
|
|
|
func TestDocument_SubResolvers(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
documentID := factory.NewDocument(owner).WithTitle("SubResolver Test Document").Create()
|
|
|
|
t.Run("organization sub-resolver", func(t *testing.T) {
|
|
query := `
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Document {
|
|
id
|
|
organization {
|
|
id
|
|
name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Organization struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
} `json:"organization"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{"id": documentID}, &result)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, owner.GetOrganizationID().String(), result.Node.Organization.ID)
|
|
assert.NotEmpty(t, result.Node.Organization.Name)
|
|
})
|
|
}
|
|
|
|
func TestDocument_RBAC(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("create", func(t *testing.T) {
|
|
t.Run("owner can create", func(t *testing.T) {
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
_, err := owner.Do(`
|
|
mutation CreateDocument($input: CreateDocumentInput!) {
|
|
createDocument(input: $input) {
|
|
documentEdge { node { id } }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": owner.GetOrganizationID().String(),
|
|
"title": "RBAC Test Document",
|
|
"content": "Test content",
|
|
"documentType": "POLICY",
|
|
"classification": "INTERNAL",
|
|
},
|
|
})
|
|
require.NoError(t, err, "owner should be able to create document")
|
|
})
|
|
|
|
t.Run("admin can create", func(t *testing.T) {
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
|
|
|
|
_, err := admin.Do(`
|
|
mutation CreateDocument($input: CreateDocumentInput!) {
|
|
createDocument(input: $input) {
|
|
documentEdge { node { id } }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": admin.GetOrganizationID().String(),
|
|
"title": "RBAC Test Document",
|
|
"content": "Test content",
|
|
"documentType": "POLICY",
|
|
"classification": "INTERNAL",
|
|
},
|
|
})
|
|
require.NoError(t, err, "admin should be able to create document")
|
|
})
|
|
|
|
t.Run("viewer cannot create", func(t *testing.T) {
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
|
|
|
_, err := viewer.Do(`
|
|
mutation CreateDocument($input: CreateDocumentInput!) {
|
|
createDocument(input: $input) {
|
|
documentEdge { node { id } }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": viewer.GetOrganizationID().String(),
|
|
"title": "RBAC Test Document",
|
|
"content": "Test content",
|
|
"documentType": "POLICY",
|
|
"classification": "INTERNAL",
|
|
},
|
|
})
|
|
testutil.RequireForbiddenError(t, err, "viewer should not be able to create document")
|
|
})
|
|
})
|
|
|
|
t.Run("update", func(t *testing.T) {
|
|
t.Run("owner can update", func(t *testing.T) {
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
documentID := factory.NewDocument(owner).WithTitle("RBAC Update Test").Create()
|
|
|
|
_, err := owner.Do(`
|
|
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
|
updateDocument(input: $input) {
|
|
document { id }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"id": documentID,
|
|
"title": "Updated by Owner",
|
|
},
|
|
})
|
|
require.NoError(t, err, "owner should be able to update document")
|
|
})
|
|
|
|
t.Run("admin can update", func(t *testing.T) {
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
|
|
|
|
documentID := factory.NewDocument(owner).WithTitle("RBAC Update Test").Create()
|
|
|
|
_, err := admin.Do(`
|
|
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
|
updateDocument(input: $input) {
|
|
document { id }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"id": documentID,
|
|
"title": "Updated by Admin",
|
|
},
|
|
})
|
|
require.NoError(t, err, "admin should be able to update document")
|
|
})
|
|
|
|
t.Run("viewer cannot update", func(t *testing.T) {
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
|
|
|
documentID := factory.NewDocument(owner).WithTitle("RBAC Update Test").Create()
|
|
|
|
_, err := viewer.Do(`
|
|
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
|
updateDocument(input: $input) {
|
|
document { id }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"id": documentID,
|
|
"title": "Updated by Viewer",
|
|
},
|
|
})
|
|
testutil.RequireForbiddenError(t, err, "viewer should not be able to update document")
|
|
})
|
|
})
|
|
|
|
t.Run("delete", func(t *testing.T) {
|
|
t.Run("owner can delete", func(t *testing.T) {
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
documentID := factory.NewDocument(owner).WithTitle("RBAC Delete Test").Create()
|
|
|
|
_, err := owner.Do(`
|
|
mutation DeleteDocument($input: DeleteDocumentInput!) {
|
|
deleteDocument(input: $input) {
|
|
deletedDocumentId
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{"documentId": documentID},
|
|
})
|
|
require.NoError(t, err, "owner should be able to delete document")
|
|
})
|
|
|
|
t.Run("admin can delete", func(t *testing.T) {
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
|
|
|
|
documentID := factory.NewDocument(owner).WithTitle("RBAC Delete Test").Create()
|
|
|
|
_, err := admin.Do(`
|
|
mutation DeleteDocument($input: DeleteDocumentInput!) {
|
|
deleteDocument(input: $input) {
|
|
deletedDocumentId
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{"documentId": documentID},
|
|
})
|
|
require.NoError(t, err, "admin should be able to delete document")
|
|
})
|
|
|
|
t.Run("viewer cannot delete", func(t *testing.T) {
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
|
|
|
documentID := factory.NewDocument(owner).WithTitle("RBAC Delete Test").Create()
|
|
|
|
_, err := viewer.Do(`
|
|
mutation DeleteDocument($input: DeleteDocumentInput!) {
|
|
deleteDocument(input: $input) {
|
|
deletedDocumentId
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{"documentId": documentID},
|
|
})
|
|
testutil.RequireForbiddenError(t, err, "viewer should not be able to delete document")
|
|
})
|
|
})
|
|
|
|
t.Run("read", func(t *testing.T) {
|
|
t.Run("owner can read", func(t *testing.T) {
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
documentID := factory.NewDocument(owner).WithTitle("RBAC Read Test").Create()
|
|
|
|
var result struct {
|
|
Node *struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := owner.Execute(`
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Document { id title }
|
|
}
|
|
}
|
|
`, map[string]any{"id": documentID}, &result)
|
|
require.NoError(t, err, "owner should be able to read document")
|
|
require.NotNil(t, result.Node, "owner should receive document data")
|
|
})
|
|
|
|
t.Run("admin can read", func(t *testing.T) {
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
|
|
|
|
documentID := factory.NewDocument(owner).WithTitle("RBAC Read Test").Create()
|
|
|
|
var result struct {
|
|
Node *struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := admin.Execute(`
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Document { id title }
|
|
}
|
|
}
|
|
`, map[string]any{"id": documentID}, &result)
|
|
require.NoError(t, err, "admin should be able to read document")
|
|
require.NotNil(t, result.Node, "admin should receive document data")
|
|
})
|
|
|
|
t.Run("viewer can read", func(t *testing.T) {
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
|
|
|
documentID := factory.NewDocument(owner).WithTitle("RBAC Read Test").Create()
|
|
|
|
var result struct {
|
|
Node *struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := viewer.Execute(`
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Document { id title }
|
|
}
|
|
}
|
|
`, map[string]any{"id": documentID}, &result)
|
|
require.NoError(t, err, "viewer should be able to read document")
|
|
require.NotNil(t, result.Node, "viewer should receive document data")
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestDocument_MaxLength_Validation(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
longTitle := strings.Repeat("a", 1001)
|
|
|
|
t.Run("create", func(t *testing.T) {
|
|
query := `
|
|
mutation CreateDocument($input: CreateDocumentInput!) {
|
|
createDocument(input: $input) {
|
|
documentEdge {
|
|
node { id }
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
_, err := owner.Do(query, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": owner.GetOrganizationID().String(),
|
|
"title": longTitle,
|
|
"content": "Test content",
|
|
"documentType": "POLICY",
|
|
"classification": "INTERNAL",
|
|
},
|
|
})
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "title")
|
|
})
|
|
|
|
t.Run("update", func(t *testing.T) {
|
|
documentID := factory.NewDocument(owner).WithTitle("Max Length Test").Create()
|
|
|
|
query := `
|
|
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
|
updateDocument(input: $input) {
|
|
document { id }
|
|
}
|
|
}
|
|
`
|
|
|
|
_, err := owner.Do(query, map[string]any{
|
|
"input": map[string]any{
|
|
"id": documentID,
|
|
"title": longTitle,
|
|
},
|
|
})
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "title")
|
|
})
|
|
}
|
|
|
|
func TestDocument_Pagination(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
for i := range 5 {
|
|
factory.NewDocument(owner).
|
|
WithTitle(fmt.Sprintf("Pagination Document %d", i)).
|
|
Create()
|
|
}
|
|
|
|
t.Run("first/after pagination", func(t *testing.T) {
|
|
query := `
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Organization {
|
|
documents(first: 2) {
|
|
edges {
|
|
node { id title }
|
|
cursor
|
|
}
|
|
pageInfo {
|
|
hasNextPage
|
|
hasPreviousPage
|
|
startCursor
|
|
endCursor
|
|
}
|
|
totalCount
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
Node struct {
|
|
Documents struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
} `json:"node"`
|
|
Cursor string `json:"cursor"`
|
|
} `json:"edges"`
|
|
PageInfo testutil.PageInfo `json:"pageInfo"`
|
|
TotalCount int `json:"totalCount"`
|
|
} `json:"documents"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := owner.Execute(
|
|
query,
|
|
map[string]any{
|
|
"id": owner.GetOrganizationID().String(),
|
|
},
|
|
&result,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
testutil.AssertFirstPage(t, len(result.Node.Documents.Edges), result.Node.Documents.PageInfo, 2, true)
|
|
assert.GreaterOrEqual(t, result.Node.Documents.TotalCount, 5)
|
|
|
|
testutil.AssertHasMorePages(t, result.Node.Documents.PageInfo)
|
|
queryAfter := `
|
|
query($id: ID!, $after: CursorKey) {
|
|
node(id: $id) {
|
|
... on Organization {
|
|
documents(first: 2, after: $after) {
|
|
edges {
|
|
node { id title }
|
|
}
|
|
pageInfo {
|
|
hasNextPage
|
|
hasPreviousPage
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var resultAfter struct {
|
|
Node struct {
|
|
Documents struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
PageInfo testutil.PageInfo `json:"pageInfo"`
|
|
} `json:"documents"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err = owner.Execute(queryAfter, map[string]any{
|
|
"id": owner.GetOrganizationID().String(),
|
|
"after": *result.Node.Documents.PageInfo.EndCursor,
|
|
}, &resultAfter)
|
|
require.NoError(t, err)
|
|
|
|
testutil.AssertMiddlePage(t, len(resultAfter.Node.Documents.Edges), resultAfter.Node.Documents.PageInfo, 2)
|
|
})
|
|
|
|
t.Run("last/before pagination", func(t *testing.T) {
|
|
query := `
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Organization {
|
|
documents(last: 2) {
|
|
edges {
|
|
node { id title }
|
|
}
|
|
pageInfo {
|
|
hasNextPage
|
|
hasPreviousPage
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
Node struct {
|
|
Documents struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
PageInfo testutil.PageInfo `json:"pageInfo"`
|
|
} `json:"documents"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"id": owner.GetOrganizationID().String(),
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
testutil.AssertLastPage(t, len(result.Node.Documents.Edges), result.Node.Documents.PageInfo, 2, true)
|
|
})
|
|
}
|
|
|
|
func TestDocument_TenantIsolation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
org1Owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
documentID := factory.NewDocument(org1Owner).WithTitle("Org1 Document").Create()
|
|
|
|
t.Run("cannot read document from another organization", func(t *testing.T) {
|
|
query := `
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Document {
|
|
id
|
|
title
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
Node *struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := org2Owner.Execute(query, map[string]any{"id": documentID}, &result)
|
|
testutil.AssertNodeNotAccessible(t, err, result.Node == nil, "document")
|
|
})
|
|
|
|
t.Run("cannot update document from another organization", func(t *testing.T) {
|
|
query := `
|
|
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
|
updateDocument(input: $input) {
|
|
document { id }
|
|
}
|
|
}
|
|
`
|
|
|
|
_, err := org2Owner.Do(query, map[string]any{
|
|
"input": map[string]any{
|
|
"id": documentID,
|
|
"title": "Hijacked Document",
|
|
},
|
|
})
|
|
require.Error(t, err, "Should not be able to update document from another org")
|
|
})
|
|
|
|
t.Run("cannot delete document from another organization", func(t *testing.T) {
|
|
query := `
|
|
mutation DeleteDocument($input: DeleteDocumentInput!) {
|
|
deleteDocument(input: $input) {
|
|
deletedDocumentId
|
|
}
|
|
}
|
|
`
|
|
|
|
_, err := org2Owner.Do(query, map[string]any{
|
|
"input": map[string]any{
|
|
"documentId": documentID,
|
|
},
|
|
})
|
|
require.Error(t, err, "Should not be able to delete document from another org")
|
|
})
|
|
|
|
t.Run("cannot list documents from another organization", func(t *testing.T) {
|
|
query := `
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Organization {
|
|
documents(first: 100) {
|
|
edges {
|
|
node {
|
|
id
|
|
title
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
Node struct {
|
|
Documents struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"documents"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := org2Owner.Execute(query, map[string]any{
|
|
"id": org1Owner.GetOrganizationID().String(),
|
|
}, &result)
|
|
|
|
if err == nil {
|
|
for _, edge := range result.Node.Documents.Edges {
|
|
assert.NotEqual(t, documentID, edge.Node.ID, "Should not see document from another org")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestDocument_Ordering(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
factory.NewDocument(owner).WithTitle("AAA Order Test").Create()
|
|
factory.NewDocument(owner).WithTitle("ZZZ Order Test").Create()
|
|
|
|
t.Run("order by created_at descending", func(t *testing.T) {
|
|
query := `
|
|
query($id: ID!, $orderBy: DocumentOrder) {
|
|
node(id: $id) {
|
|
... on Organization {
|
|
documents(first: 100, orderBy: $orderBy) {
|
|
edges {
|
|
node {
|
|
id
|
|
createdAt
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
Node struct {
|
|
Documents struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"documents"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"id": owner.GetOrganizationID().String(),
|
|
"orderBy": map[string]any{
|
|
"field": "CREATED_AT",
|
|
"direction": "DESC",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
times := make([]time.Time, len(result.Node.Documents.Edges))
|
|
for i, edge := range result.Node.Documents.Edges {
|
|
times[i] = edge.Node.CreatedAt
|
|
}
|
|
testutil.AssertTimesOrderedDescending(t, times, "createdAt")
|
|
})
|
|
}
|