Archive and bulk archive now void pending approval quorums and cancel requested signatures before freezing the document. Unarchive reverts any leftover pending approval to draft. A migration backfills archived documents that still carry in-flight workflow rows. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
2574 lines
69 KiB
Go
2574 lines
69 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"
|
|
|
|
"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 getOwnerProfileID(t *testing.T, owner *testutil.Client) string {
|
|
t.Helper()
|
|
|
|
return owner.GetProfileID().String()
|
|
}
|
|
|
|
// createTestDocument creates a document and returns its ID and the document version ID
|
|
func createTestDocument(t *testing.T, owner *testutil.Client) (docID string, docVersionID string) {
|
|
t.Helper()
|
|
|
|
query := `
|
|
mutation CreateDocument($input: CreateDocumentInput!) {
|
|
createDocument(input: $input) {
|
|
documentEdge {
|
|
node {
|
|
id
|
|
versions(first: 1) {
|
|
edges {
|
|
node {
|
|
id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
CreateDocument struct {
|
|
DocumentEdge struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Versions struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"versions"`
|
|
} `json:"node"`
|
|
} `json:"documentEdge"`
|
|
} `json:"createDocument"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": owner.GetOrganizationID().String(),
|
|
"title": "Test Document",
|
|
"content": testutil.ProseMirrorTextDoc("Initial content"),
|
|
"documentType": "POLICY",
|
|
"classification": "INTERNAL",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
docID = result.CreateDocument.DocumentEdge.Node.ID
|
|
if len(result.CreateDocument.DocumentEdge.Node.Versions.Edges) > 0 {
|
|
docVersionID = result.CreateDocument.DocumentEdge.Node.Versions.Edges[0].Node.ID
|
|
}
|
|
|
|
return docID, docVersionID
|
|
}
|
|
|
|
// approveTestDocument requests approval and approves the document so it can be published.
|
|
func approveTestDocument(t *testing.T, owner *testutil.Client, docID string) {
|
|
t.Helper()
|
|
|
|
requestDocumentApproval(t, owner, docID, []string{getOwnerProfileID(t, owner)})
|
|
approveLatestDocumentVersion(t, owner, docID)
|
|
}
|
|
|
|
// latestDocumentVersionID returns the ID of the document's most recently created version.
|
|
func latestDocumentVersionID(t *testing.T, owner *testutil.Client, docID string) string {
|
|
t.Helper()
|
|
|
|
var result struct {
|
|
Node struct {
|
|
Versions struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"versions"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := owner.Execute(`
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Document {
|
|
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
|
edges { node { id } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{"id": docID}, &result)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, result.Node.Versions.Edges)
|
|
|
|
return result.Node.Versions.Edges[0].Node.ID
|
|
}
|
|
|
|
// requestDocumentApproval opens a major approval quorum on the document's draft.
|
|
func requestDocumentApproval(t *testing.T, owner *testutil.Client, docID string, approverIDs []string) {
|
|
t.Helper()
|
|
|
|
_, err := owner.Do(`
|
|
mutation($input: PublishDocumentInput!) {
|
|
publishDocument(input: $input) {
|
|
approvalQuorum { id }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"minor": false,
|
|
"documentId": docID,
|
|
"approverIds": approverIDs,
|
|
"changelog": "Test changelog",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// approveLatestDocumentVersion approves the document's most recent version.
|
|
func approveLatestDocumentVersion(t *testing.T, owner *testutil.Client, docID string) {
|
|
t.Helper()
|
|
|
|
_, err := owner.Do(`
|
|
mutation($input: ApproveDocumentVersionInput!) {
|
|
approveDocumentVersion(input: $input) {
|
|
approvalDecision { id state }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"documentVersionId": latestDocumentVersionID(t, owner, docID),
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// publishMajorDocumentVersion publishes the document's draft as a major version
|
|
// without approvers and returns the published version ID.
|
|
func publishMajorDocumentVersion(t *testing.T, owner *testutil.Client, docID string) string {
|
|
t.Helper()
|
|
|
|
var result struct {
|
|
PublishDocument struct {
|
|
DocumentVersion struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
} `json:"documentVersion"`
|
|
} `json:"publishDocument"`
|
|
}
|
|
|
|
err := owner.Execute(`
|
|
mutation($input: PublishDocumentInput!) {
|
|
publishDocument(input: $input) {
|
|
documentVersion { id status }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"minor": false,
|
|
"documentId": docID,
|
|
"approverIds": []string{},
|
|
"changelog": "Major release",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "PUBLISHED", result.PublishDocument.DocumentVersion.Status)
|
|
|
|
return result.PublishDocument.DocumentVersion.ID
|
|
}
|
|
|
|
// publishMinorDocumentVersion publishes the document's draft as a minor version
|
|
// and returns the published version ID.
|
|
func publishMinorDocumentVersion(t *testing.T, owner *testutil.Client, docID string) string {
|
|
t.Helper()
|
|
|
|
var result struct {
|
|
PublishDocument struct {
|
|
DocumentVersion struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
} `json:"documentVersion"`
|
|
} `json:"publishDocument"`
|
|
}
|
|
|
|
err := owner.Execute(`
|
|
mutation($input: PublishDocumentInput!) {
|
|
publishDocument(input: $input) {
|
|
documentVersion { id status }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"minor": true,
|
|
"documentId": docID,
|
|
"changelog": "Minor release",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "PUBLISHED", result.PublishDocument.DocumentVersion.Status)
|
|
|
|
return result.PublishDocument.DocumentVersion.ID
|
|
}
|
|
|
|
// updateDocumentContent edits the document so a fresh draft is created.
|
|
func updateDocumentContent(t *testing.T, owner *testutil.Client, docID, content string) {
|
|
t.Helper()
|
|
|
|
_, err := owner.Do(`
|
|
mutation($input: UpdateDocumentInput!) {
|
|
updateDocument(input: $input) {
|
|
documentVersion { id }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"id": docID,
|
|
"content": testutil.ProseMirrorTextDoc(content),
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// requestDocumentSignature requests a signature on the version from the signatory.
|
|
func requestDocumentSignature(t *testing.T, owner *testutil.Client, versionID, signatoryID string) {
|
|
t.Helper()
|
|
|
|
_, err := owner.Do(`
|
|
mutation($input: RequestSignatureInput!) {
|
|
requestSignature(input: $input) {
|
|
documentVersionSignatureEdge { node { id } }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"documentVersionId": versionID,
|
|
"signatoryId": signatoryID,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestDocumentVersion_PublishVersion(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
approveTestDocument(t, owner, docID)
|
|
|
|
// After approval, the version is auto-published.
|
|
// Verify the version status by querying.
|
|
query := `
|
|
query GetDocument($id: ID!) {
|
|
node(id: $id) {
|
|
... on Document {
|
|
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
|
edges {
|
|
node {
|
|
id
|
|
status
|
|
major
|
|
minor
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
Node struct {
|
|
Versions struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
Major int `json:"major"`
|
|
Minor int `json:"minor"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"versions"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{"id": docID}, &result)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, result.Node.Versions.Edges)
|
|
|
|
assert.Equal(t, "PUBLISHED", result.Node.Versions.Edges[0].Node.Status)
|
|
assert.Equal(t, 1, result.Node.Versions.Edges[0].Node.Major)
|
|
assert.Equal(t, 0, result.Node.Versions.Edges[0].Node.Minor)
|
|
}
|
|
|
|
func TestDocumentVersion_PublishInitialMinorVersion(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
|
|
publishMutation := `
|
|
mutation($input: PublishDocumentInput!) {
|
|
publishDocument(input: $input) {
|
|
document {
|
|
currentPublishedMajor
|
|
currentPublishedMinor
|
|
}
|
|
documentVersion {
|
|
status
|
|
major
|
|
minor
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var publishResult struct {
|
|
PublishDocument struct {
|
|
Document struct {
|
|
CurrentPublishedMajor *int `json:"currentPublishedMajor"`
|
|
CurrentPublishedMinor *int `json:"currentPublishedMinor"`
|
|
} `json:"document"`
|
|
DocumentVersion struct {
|
|
Status string `json:"status"`
|
|
Major int `json:"major"`
|
|
Minor int `json:"minor"`
|
|
} `json:"documentVersion"`
|
|
} `json:"publishDocument"`
|
|
}
|
|
|
|
err := owner.Execute(publishMutation, map[string]any{
|
|
"input": map[string]any{
|
|
"minor": true,
|
|
"documentId": docID,
|
|
"changelog": "Initial minor release",
|
|
},
|
|
}, &publishResult)
|
|
require.NoError(t, err)
|
|
|
|
require.NotNil(t, publishResult.PublishDocument.Document.CurrentPublishedMajor)
|
|
require.NotNil(t, publishResult.PublishDocument.Document.CurrentPublishedMinor)
|
|
assert.Equal(t, 0, *publishResult.PublishDocument.Document.CurrentPublishedMajor)
|
|
assert.Equal(t, 1, *publishResult.PublishDocument.Document.CurrentPublishedMinor)
|
|
assert.Equal(t, "PUBLISHED", publishResult.PublishDocument.DocumentVersion.Status)
|
|
assert.Equal(t, 0, publishResult.PublishDocument.DocumentVersion.Major)
|
|
assert.Equal(t, 1, publishResult.PublishDocument.DocumentVersion.Minor)
|
|
|
|
var updateResult struct {
|
|
UpdateDocument struct {
|
|
DocumentVersion *struct {
|
|
Status string `json:"status"`
|
|
Major int `json:"major"`
|
|
Minor int `json:"minor"`
|
|
} `json:"documentVersion"`
|
|
} `json:"updateDocument"`
|
|
}
|
|
|
|
err = owner.Execute(`
|
|
mutation($input: UpdateDocumentInput!) {
|
|
updateDocument(input: $input) {
|
|
documentVersion {
|
|
status
|
|
major
|
|
minor
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"id": docID,
|
|
"content": testutil.ProseMirrorTextDoc("Updated content for 0.2"),
|
|
},
|
|
}, &updateResult)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, updateResult.UpdateDocument.DocumentVersion)
|
|
assert.Equal(t, "DRAFT", updateResult.UpdateDocument.DocumentVersion.Status)
|
|
assert.Equal(t, 0, updateResult.UpdateDocument.DocumentVersion.Major)
|
|
assert.Equal(t, 2, updateResult.UpdateDocument.DocumentVersion.Minor)
|
|
|
|
err = owner.Execute(publishMutation, map[string]any{
|
|
"input": map[string]any{
|
|
"minor": true,
|
|
"documentId": docID,
|
|
"changelog": "Second minor release",
|
|
},
|
|
}, &publishResult)
|
|
require.NoError(t, err)
|
|
|
|
require.NotNil(t, publishResult.PublishDocument.Document.CurrentPublishedMajor)
|
|
require.NotNil(t, publishResult.PublishDocument.Document.CurrentPublishedMinor)
|
|
assert.Equal(t, 0, *publishResult.PublishDocument.Document.CurrentPublishedMajor)
|
|
assert.Equal(t, 2, *publishResult.PublishDocument.Document.CurrentPublishedMinor)
|
|
assert.Equal(t, "PUBLISHED", publishResult.PublishDocument.DocumentVersion.Status)
|
|
assert.Equal(t, 0, publishResult.PublishDocument.DocumentVersion.Major)
|
|
assert.Equal(t, 2, publishResult.PublishDocument.DocumentVersion.Minor)
|
|
}
|
|
|
|
func TestDocumentVersion_AutoCreateDraft(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
// Create and approve a document (auto-publishes on approval)
|
|
docID, _ := createTestDocument(t, owner)
|
|
approveTestDocument(t, owner, docID)
|
|
|
|
// Updating content should auto-create a draft
|
|
query := `
|
|
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
|
updateDocument(input: $input) {
|
|
document {
|
|
id
|
|
}
|
|
documentVersion {
|
|
id
|
|
status
|
|
content
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
UpdateDocument struct {
|
|
Document struct {
|
|
ID string `json:"id"`
|
|
} `json:"document"`
|
|
DocumentVersion *struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
Content string `json:"content"`
|
|
} `json:"documentVersion"`
|
|
} `json:"updateDocument"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"id": docID,
|
|
"content": testutil.ProseMirrorTextDoc("Updated content"),
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
require.NotNil(t, result.UpdateDocument.DocumentVersion)
|
|
assert.Equal(t, "DRAFT", result.UpdateDocument.DocumentVersion.Status)
|
|
}
|
|
|
|
func TestDocumentVersion_AutoDeleteDraft(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
// Create and approve a document (auto-publishes on approval)
|
|
docID, _ := createTestDocument(t, owner)
|
|
approveTestDocument(t, owner, docID)
|
|
|
|
// First update to create a draft
|
|
query := `
|
|
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
|
updateDocument(input: $input) {
|
|
document {
|
|
id
|
|
}
|
|
documentVersion {
|
|
id
|
|
status
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var createResult struct {
|
|
UpdateDocument struct {
|
|
Document struct {
|
|
ID string `json:"id"`
|
|
} `json:"document"`
|
|
DocumentVersion *struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
} `json:"documentVersion"`
|
|
} `json:"updateDocument"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"id": docID,
|
|
"content": testutil.ProseMirrorTextDoc("Updated content"),
|
|
},
|
|
}, &createResult)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, createResult.UpdateDocument.DocumentVersion)
|
|
|
|
// Now revert content to match the published version — draft should be auto-deleted
|
|
var revertResult struct {
|
|
UpdateDocument struct {
|
|
Document struct {
|
|
ID string `json:"id"`
|
|
} `json:"document"`
|
|
DocumentVersion *struct {
|
|
ID string `json:"id"`
|
|
} `json:"documentVersion"`
|
|
} `json:"updateDocument"`
|
|
}
|
|
|
|
err = owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"id": docID,
|
|
"content": testutil.ProseMirrorTextDoc("Initial content"),
|
|
},
|
|
}, &revertResult)
|
|
require.NoError(t, err)
|
|
|
|
assert.Nil(t, revertResult.UpdateDocument.DocumentVersion)
|
|
}
|
|
|
|
func TestDocumentVersion_RequestSignature(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
// Create and approve a document (auto-publishes on approval)
|
|
docID, _ := createTestDocument(t, owner)
|
|
approveTestDocument(t, owner, docID)
|
|
|
|
// Get the published version ID
|
|
versionQuery := `
|
|
query GetVersions($id: ID!) {
|
|
node(id: $id) {
|
|
... on Document {
|
|
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
|
edges {
|
|
node {
|
|
id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var versionResult struct {
|
|
Node struct {
|
|
Versions struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"versions"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := owner.Execute(versionQuery, map[string]any{"id": docID}, &versionResult)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, versionResult.Node.Versions.Edges)
|
|
|
|
publishedVersionID := versionResult.Node.Versions.Edges[0].Node.ID
|
|
|
|
// Create a person to sign
|
|
signerProfileID := factory.CreateUser(owner)
|
|
|
|
query := `
|
|
mutation RequestSignature($input: RequestSignatureInput!) {
|
|
requestSignature(input: $input) {
|
|
documentVersionSignatureEdge {
|
|
node {
|
|
id
|
|
state
|
|
signedBy {
|
|
id
|
|
fullName
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
RequestSignature struct {
|
|
DocumentVersionSignatureEdge struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
State string `json:"state"`
|
|
SignedBy struct {
|
|
ID string `json:"id"`
|
|
FullName string `json:"fullName"`
|
|
} `json:"signedBy"`
|
|
} `json:"node"`
|
|
} `json:"documentVersionSignatureEdge"`
|
|
} `json:"requestSignature"`
|
|
}
|
|
|
|
err = owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"documentVersionId": publishedVersionID,
|
|
"signatoryId": signerProfileID,
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
assert.NotEmpty(t, result.RequestSignature.DocumentVersionSignatureEdge.Node.ID)
|
|
assert.Equal(t, "REQUESTED", result.RequestSignature.DocumentVersionSignatureEdge.Node.State)
|
|
assert.Equal(t, signerProfileID, result.RequestSignature.DocumentVersionSignatureEdge.Node.SignedBy.ID)
|
|
}
|
|
|
|
func createTestDocumentWithApprovers(t *testing.T, owner *testutil.Client, approverIDs []string) (docID string) {
|
|
t.Helper()
|
|
|
|
var result struct {
|
|
CreateDocument struct {
|
|
DocumentEdge struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
} `json:"node"`
|
|
} `json:"documentEdge"`
|
|
} `json:"createDocument"`
|
|
}
|
|
|
|
err := owner.Execute(`
|
|
mutation($input: CreateDocumentInput!) {
|
|
createDocument(input: $input) {
|
|
documentEdge {
|
|
node { id }
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": owner.GetOrganizationID().String(),
|
|
"title": "Test Document With Approvers",
|
|
"content": testutil.ProseMirrorTextDoc("Initial content"),
|
|
"documentType": "POLICY",
|
|
"classification": "INTERNAL",
|
|
"defaultApproverIds": approverIDs,
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
return result.CreateDocument.DocumentEdge.Node.ID
|
|
}
|
|
|
|
func TestDocumentVersion_BulkPublish(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
// Create multiple draft documents (no default approvers — should publish directly)
|
|
docID1, _ := createTestDocument(t, owner)
|
|
docID2, _ := createTestDocument(t, owner)
|
|
|
|
query := `
|
|
mutation BulkPublishDocuments($input: BulkPublishDocumentsInput!) {
|
|
bulkPublishDocuments(input: $input) {
|
|
documentVersions {
|
|
id
|
|
status
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
BulkPublishDocuments struct {
|
|
DocumentVersions []struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
} `json:"documentVersions"`
|
|
} `json:"bulkPublishDocuments"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"minor": false,
|
|
"documentIds": []string{docID1, docID2},
|
|
"changelog": "Bulk publish release",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, 2, len(result.BulkPublishDocuments.DocumentVersions))
|
|
|
|
for _, dv := range result.BulkPublishDocuments.DocumentVersions {
|
|
assert.Equal(t, "PUBLISHED", dv.Status)
|
|
}
|
|
}
|
|
|
|
func TestDocumentVersion_BulkPublishRequestsApproval(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
approverID := getOwnerProfileID(t, owner)
|
|
|
|
// Create a document with default approvers
|
|
docID := createTestDocumentWithApprovers(t, owner, []string{approverID})
|
|
|
|
query := `
|
|
mutation BulkPublishDocuments($input: BulkPublishDocumentsInput!) {
|
|
bulkPublishDocuments(input: $input) {
|
|
documentVersions {
|
|
id
|
|
status
|
|
major
|
|
minor
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
BulkPublishDocuments struct {
|
|
DocumentVersions []struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
Major int `json:"major"`
|
|
Minor int `json:"minor"`
|
|
} `json:"documentVersions"`
|
|
} `json:"bulkPublishDocuments"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"minor": false,
|
|
"documentIds": []string{docID},
|
|
"changelog": "Needs approval",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, result.BulkPublishDocuments.DocumentVersions, 1)
|
|
dv := result.BulkPublishDocuments.DocumentVersions[0]
|
|
assert.Equal(t, "PENDING_APPROVAL", dv.Status)
|
|
assert.Equal(t, 1, dv.Major)
|
|
assert.Equal(t, 0, dv.Minor)
|
|
}
|
|
|
|
func TestDocumentVersion_BulkPublishSkipsPendingApproval(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
approverID := getOwnerProfileID(t, owner)
|
|
|
|
// Create a document with default approvers and bulk publish it (puts it in PENDING_APPROVAL)
|
|
docID := createTestDocumentWithApprovers(t, owner, []string{approverID})
|
|
|
|
_, err := owner.Do(`
|
|
mutation($input: BulkPublishDocumentsInput!) {
|
|
bulkPublishDocuments(input: $input) {
|
|
documentVersions { id }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"minor": false,
|
|
"documentIds": []string{docID},
|
|
"changelog": "First approval request",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Bulk publish again — should skip the pending document and return empty
|
|
var result struct {
|
|
BulkPublishDocuments struct {
|
|
DocumentVersions []struct {
|
|
ID string `json:"id"`
|
|
} `json:"documentVersions"`
|
|
} `json:"bulkPublishDocuments"`
|
|
}
|
|
|
|
err = owner.Execute(`
|
|
mutation($input: BulkPublishDocumentsInput!) {
|
|
bulkPublishDocuments(input: $input) {
|
|
documentVersions { id }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"minor": false,
|
|
"documentIds": []string{docID},
|
|
"changelog": "Second attempt",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
assert.Empty(t, result.BulkPublishDocuments.DocumentVersions)
|
|
}
|
|
|
|
func TestDocumentVersion_BulkPublishMinorSkipsPendingApproval(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
// Create and publish a document first (need a published version for minor publish)
|
|
docID, _ := createTestDocument(t, owner)
|
|
approveTestDocument(t, owner, docID)
|
|
|
|
// Create a draft by updating content (auto-creates draft)
|
|
_, err := owner.Do(`
|
|
mutation($input: UpdateDocumentInput!) {
|
|
updateDocument(input: $input) {
|
|
documentVersion { id }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"id": docID,
|
|
"content": testutil.ProseMirrorTextDoc("Updated content to create a draft"),
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Request approval to put it in PENDING_APPROVAL
|
|
approverID := getOwnerProfileID(t, owner)
|
|
_, err = owner.Do(`
|
|
mutation($input: PublishDocumentInput!) {
|
|
publishDocument(input: $input) {
|
|
approvalQuorum { id }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"minor": false,
|
|
"documentId": docID,
|
|
"approverIds": []string{approverID},
|
|
"changelog": "Approval request",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Bulk publish minor — should skip the pending document
|
|
var result struct {
|
|
BulkPublishDocuments struct {
|
|
DocumentVersions []struct {
|
|
ID string `json:"id"`
|
|
} `json:"documentVersions"`
|
|
} `json:"bulkPublishDocuments"`
|
|
}
|
|
|
|
err = owner.Execute(`
|
|
mutation($input: BulkPublishDocumentsInput!) {
|
|
bulkPublishDocuments(input: $input) {
|
|
documentVersions { id }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"minor": true,
|
|
"documentIds": []string{docID},
|
|
"changelog": "Minor publish attempt",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
assert.Empty(t, result.BulkPublishDocuments.DocumentVersions)
|
|
}
|
|
|
|
func TestDocumentVersion_BulkRequestSignatures(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
// Create and approve a document (auto-publishes on approval)
|
|
docID, _ := createTestDocument(t, owner)
|
|
approveTestDocument(t, owner, docID)
|
|
|
|
// Create multiple signers
|
|
signer1ProfileID := factory.CreateUser(owner)
|
|
signer2ProfileID := factory.CreateUser(owner)
|
|
|
|
query := `
|
|
mutation BulkRequestSignatures($input: BulkRequestSignaturesInput!) {
|
|
bulkRequestSignatures(input: $input) {
|
|
documentVersionSignatureEdges {
|
|
node {
|
|
id
|
|
state
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
BulkRequestSignatures struct {
|
|
DocumentVersionSignatureEdges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
State string `json:"state"`
|
|
} `json:"node"`
|
|
} `json:"documentVersionSignatureEdges"`
|
|
} `json:"bulkRequestSignatures"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"documentIds": []string{docID},
|
|
"signatoryIds": []string{signer1ProfileID, signer2ProfileID},
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, 2, len(result.BulkRequestSignatures.DocumentVersionSignatureEdges))
|
|
|
|
for _, edge := range result.BulkRequestSignatures.DocumentVersionSignatureEdges {
|
|
assert.Equal(t, "REQUESTED", edge.Node.State)
|
|
}
|
|
}
|
|
|
|
func TestDocumentVersion_AutoCreateDraftOnClassificationOrTypeUpdate(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
t.Run("documentType update creates draft", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Create and approve a document (auto-publishes on approval)
|
|
docID, _ := createTestDocument(t, owner)
|
|
approveTestDocument(t, owner, docID)
|
|
|
|
// Updating documentType should auto-create a draft
|
|
query := `
|
|
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
|
updateDocument(input: $input) {
|
|
document {
|
|
id
|
|
}
|
|
documentVersion {
|
|
id
|
|
status
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
UpdateDocument struct {
|
|
Document struct {
|
|
ID string `json:"id"`
|
|
} `json:"document"`
|
|
DocumentVersion *struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
} `json:"documentVersion"`
|
|
} `json:"updateDocument"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"id": docID,
|
|
"documentType": "PROCEDURE",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
require.NotNil(t, result.UpdateDocument.DocumentVersion)
|
|
assert.Equal(t, "DRAFT", result.UpdateDocument.DocumentVersion.Status)
|
|
})
|
|
|
|
t.Run("classification update creates draft", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Create and approve a document (auto-publishes on approval)
|
|
docID, _ := createTestDocument(t, owner)
|
|
approveTestDocument(t, owner, docID)
|
|
|
|
// Updating classification should auto-create a draft
|
|
query := `
|
|
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
|
updateDocument(input: $input) {
|
|
document {
|
|
id
|
|
}
|
|
documentVersion {
|
|
id
|
|
status
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
UpdateDocument struct {
|
|
Document struct {
|
|
ID string `json:"id"`
|
|
} `json:"document"`
|
|
DocumentVersion *struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
} `json:"documentVersion"`
|
|
} `json:"updateDocument"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"id": docID,
|
|
"classification": "CONFIDENTIAL",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
require.NotNil(t, result.UpdateDocument.DocumentVersion)
|
|
assert.Equal(t, "DRAFT", result.UpdateDocument.DocumentVersion.Status)
|
|
})
|
|
}
|
|
|
|
func TestDocumentVersion_ViewerCannotUpdateDocument(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
|
|
|
// Create and approve a document (auto-publishes on approval)
|
|
docID, _ := createTestDocument(t, owner)
|
|
approveTestDocument(t, owner, docID)
|
|
|
|
// Viewer attempts to update content on the published document
|
|
_, err := viewer.Do(`
|
|
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
|
updateDocument(input: $input) {
|
|
document { id }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"id": docID,
|
|
"content": testutil.ProseMirrorTextDoc("Viewer updated content"),
|
|
},
|
|
})
|
|
testutil.RequireForbiddenError(t, err, "viewer should not be able to update document")
|
|
}
|
|
|
|
func TestDocumentVersion_BulkDelete(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
// Create multiple documents to delete
|
|
docID1, _ := createTestDocument(t, owner)
|
|
docID2, _ := createTestDocument(t, owner)
|
|
|
|
query := `
|
|
mutation BulkDeleteDocuments($input: BulkDeleteDocumentsInput!) {
|
|
bulkDeleteDocuments(input: $input) {
|
|
deletedDocumentIds
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
BulkDeleteDocuments struct {
|
|
DeletedDocumentIds []string `json:"deletedDocumentIds"`
|
|
} `json:"bulkDeleteDocuments"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"documentIds": []string{docID1, docID2},
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, 2, len(result.BulkDeleteDocuments.DeletedDocumentIds))
|
|
assert.Contains(t, result.BulkDeleteDocuments.DeletedDocumentIds, docID1)
|
|
assert.Contains(t, result.BulkDeleteDocuments.DeletedDocumentIds, docID2)
|
|
}
|
|
|
|
func TestDocumentVersion_VoidApproval(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
approverID := getOwnerProfileID(t, owner)
|
|
|
|
// Request approval
|
|
_, err := owner.Do(`
|
|
mutation($input: PublishDocumentInput!) {
|
|
publishDocument(input: $input) {
|
|
approvalQuorum { id }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"minor": false,
|
|
"documentId": docID,
|
|
"approverIds": []string{approverID},
|
|
"changelog": "Test changelog",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Get version ID and verify version bumped to 1.0
|
|
var versionResult struct {
|
|
Node struct {
|
|
Versions struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
Major int `json:"major"`
|
|
Minor int `json:"minor"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"versions"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err = owner.Execute(`
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Document {
|
|
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
|
edges { node { id status major minor } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{"id": docID}, &versionResult)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, versionResult.Node.Versions.Edges)
|
|
assert.Equal(t, "PENDING_APPROVAL", versionResult.Node.Versions.Edges[0].Node.Status)
|
|
assert.Equal(t, 1, versionResult.Node.Versions.Edges[0].Node.Major)
|
|
assert.Equal(t, 0, versionResult.Node.Versions.Edges[0].Node.Minor)
|
|
|
|
versionID := versionResult.Node.Versions.Edges[0].Node.ID
|
|
|
|
// Void approval — version should revert to 0.1
|
|
var voidResult struct {
|
|
VoidDocumentVersionApproval struct {
|
|
ApprovalQuorum struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
} `json:"approvalQuorum"`
|
|
DocumentVersion struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
Major int `json:"major"`
|
|
Minor int `json:"minor"`
|
|
} `json:"documentVersion"`
|
|
} `json:"voidDocumentVersionApproval"`
|
|
}
|
|
|
|
err = owner.Execute(`
|
|
mutation($input: VoidDocumentVersionApprovalInput!) {
|
|
voidDocumentVersionApproval(input: $input) {
|
|
approvalQuorum { id status }
|
|
documentVersion { id status major minor }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"documentVersionId": versionID,
|
|
},
|
|
}, &voidResult)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "VOIDED", voidResult.VoidDocumentVersionApproval.ApprovalQuorum.Status)
|
|
assert.Equal(t, "DRAFT", voidResult.VoidDocumentVersionApproval.DocumentVersion.Status)
|
|
assert.Equal(t, 0, voidResult.VoidDocumentVersionApproval.DocumentVersion.Major)
|
|
assert.Equal(t, 1, voidResult.VoidDocumentVersionApproval.DocumentVersion.Minor)
|
|
|
|
// Verify decisions are VOIDED after voiding
|
|
var quorumResult struct {
|
|
Node struct {
|
|
Versions struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ApprovalQuorums struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
Decisions struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
State string `json:"state"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"decisions"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"approvalQuorums"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"versions"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err = owner.Execute(`
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Document {
|
|
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
|
edges {
|
|
node {
|
|
approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
|
edges {
|
|
node {
|
|
decisions(first: 100) {
|
|
edges { node { state } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{"id": docID}, &quorumResult)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, quorumResult.Node.Versions.Edges)
|
|
require.NotEmpty(t, quorumResult.Node.Versions.Edges[0].Node.ApprovalQuorums.Edges)
|
|
decisions := quorumResult.Node.Versions.Edges[0].Node.ApprovalQuorums.Edges[0].Node.Decisions.Edges
|
|
require.NotEmpty(t, decisions)
|
|
|
|
for _, d := range decisions {
|
|
assert.Equal(t, "VOIDED", d.Node.State, "decisions should be VOIDED after voiding")
|
|
}
|
|
}
|
|
|
|
func TestDocumentVersion_RejectApproval(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
approverID := getOwnerProfileID(t, owner)
|
|
|
|
// Request approval — version should bump to 1.0
|
|
_, err := owner.Do(`
|
|
mutation($input: PublishDocumentInput!) {
|
|
publishDocument(input: $input) {
|
|
approvalQuorum { id }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"minor": false,
|
|
"documentId": docID,
|
|
"approverIds": []string{approverID},
|
|
"changelog": "Test changelog",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Get version ID
|
|
var versionResult struct {
|
|
Node struct {
|
|
Versions struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
Major int `json:"major"`
|
|
Minor int `json:"minor"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"versions"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err = owner.Execute(`
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Document {
|
|
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
|
edges { node { id status major minor } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{"id": docID}, &versionResult)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, versionResult.Node.Versions.Edges)
|
|
assert.Equal(t, "PENDING_APPROVAL", versionResult.Node.Versions.Edges[0].Node.Status)
|
|
assert.Equal(t, 1, versionResult.Node.Versions.Edges[0].Node.Major)
|
|
assert.Equal(t, 0, versionResult.Node.Versions.Edges[0].Node.Minor)
|
|
|
|
versionID := versionResult.Node.Versions.Edges[0].Node.ID
|
|
|
|
// Reject approval
|
|
_, err = owner.Do(`
|
|
mutation($input: RejectDocumentVersionInput!) {
|
|
rejectDocumentVersion(input: $input) {
|
|
approvalDecision { id state }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"documentVersionId": versionID,
|
|
"comment": "Needs rework",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Verify version reverted to 0.1 DRAFT
|
|
err = owner.Execute(`
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Document {
|
|
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
|
edges { node { id status major minor } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{"id": docID}, &versionResult)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, versionResult.Node.Versions.Edges)
|
|
assert.Equal(t, "DRAFT", versionResult.Node.Versions.Edges[0].Node.Status)
|
|
assert.Equal(t, 0, versionResult.Node.Versions.Edges[0].Node.Major)
|
|
assert.Equal(t, 1, versionResult.Node.Versions.Edges[0].Node.Minor)
|
|
|
|
// Verify decisions are VOIDED after reject
|
|
var quorumResult struct {
|
|
Node struct {
|
|
Versions struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ApprovalQuorums struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
Decisions struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
State string `json:"state"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"decisions"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"approvalQuorums"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"versions"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err = owner.Execute(`
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Document {
|
|
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
|
edges {
|
|
node {
|
|
approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
|
edges {
|
|
node {
|
|
decisions(first: 100) {
|
|
edges { node { state } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{"id": docID}, &quorumResult)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, quorumResult.Node.Versions.Edges)
|
|
require.NotEmpty(t, quorumResult.Node.Versions.Edges[0].Node.ApprovalQuorums.Edges)
|
|
decisions := quorumResult.Node.Versions.Edges[0].Node.ApprovalQuorums.Edges[0].Node.Decisions.Edges
|
|
require.Len(t, decisions, 1)
|
|
assert.Equal(t, "REJECTED", decisions[0].Node.State, "rejecting approver's decision should be REJECTED")
|
|
}
|
|
|
|
func TestDocumentVersion_PublishBlockedWhenPendingApproval(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
approverID := getOwnerProfileID(t, owner)
|
|
|
|
// Request approval (puts version in PENDING_APPROVAL)
|
|
_, err := owner.Do(`
|
|
mutation($input: PublishDocumentInput!) {
|
|
publishDocument(input: $input) {
|
|
approvalQuorum { id }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"minor": false,
|
|
"documentId": docID,
|
|
"approverIds": []string{approverID},
|
|
"changelog": "Test changelog",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
t.Run("publish major blocked", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := owner.Do(`
|
|
mutation($input: PublishDocumentInput!) {
|
|
publishDocument(input: $input) {
|
|
documentVersion { id }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"minor": false,
|
|
"documentId": docID,
|
|
"approverIds": []string{},
|
|
"changelog": "Major release",
|
|
},
|
|
})
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("publish minor blocked", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := owner.Do(`
|
|
mutation($input: PublishDocumentInput!) {
|
|
publishDocument(input: $input) {
|
|
documentVersion { id }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"minor": true,
|
|
"documentId": docID,
|
|
"changelog": "Minor release",
|
|
},
|
|
})
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
|
|
// TestDocumentVersion_PublishApproverIDsContract verifies that approver_ids must
|
|
// be an explicit choice when publishing: required for a major version (empty
|
|
// list publishes directly, non-empty requests approval) and rejected for a minor
|
|
// version (which ignores approvers).
|
|
func TestDocumentVersion_PublishApproverIDsContract(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const publishMutation = `
|
|
mutation($input: PublishDocumentInput!) {
|
|
publishDocument(input: $input) {
|
|
documentVersion { status }
|
|
approvalQuorum { id }
|
|
}
|
|
}
|
|
`
|
|
|
|
type publishResult struct {
|
|
PublishDocument struct {
|
|
DocumentVersion struct {
|
|
Status string `json:"status"`
|
|
} `json:"documentVersion"`
|
|
ApprovalQuorum *struct {
|
|
ID string `json:"id"`
|
|
} `json:"approvalQuorum"`
|
|
} `json:"publishDocument"`
|
|
}
|
|
|
|
t.Run("major publish without approver_ids is rejected", func(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
docID, _ := createTestDocument(t, owner)
|
|
|
|
_, err := owner.Do(publishMutation, map[string]any{
|
|
"input": map[string]any{
|
|
"minor": false,
|
|
"documentId": docID,
|
|
"changelog": "Major release",
|
|
},
|
|
})
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("major publish with empty approver_ids publishes directly", func(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
docID, _ := createTestDocument(t, owner)
|
|
|
|
var result publishResult
|
|
|
|
err := owner.Execute(publishMutation, map[string]any{
|
|
"input": map[string]any{
|
|
"minor": false,
|
|
"documentId": docID,
|
|
"approverIds": []string{},
|
|
"changelog": "Direct major release",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "PUBLISHED", result.PublishDocument.DocumentVersion.Status)
|
|
assert.Nil(t, result.PublishDocument.ApprovalQuorum)
|
|
})
|
|
|
|
t.Run("major publish with approver_ids requests approval", func(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
docID, _ := createTestDocument(t, owner)
|
|
|
|
var result publishResult
|
|
|
|
err := owner.Execute(publishMutation, map[string]any{
|
|
"input": map[string]any{
|
|
"minor": false,
|
|
"documentId": docID,
|
|
"approverIds": []string{getOwnerProfileID(t, owner)},
|
|
"changelog": "Major release needing approval",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "PENDING_APPROVAL", result.PublishDocument.DocumentVersion.Status)
|
|
require.NotNil(t, result.PublishDocument.ApprovalQuorum)
|
|
})
|
|
|
|
t.Run("minor publish without approver_ids is allowed", func(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
docID, _ := createTestDocument(t, owner)
|
|
publishMajorDocumentVersion(t, owner, docID)
|
|
updateDocumentContent(t, owner, docID, "Updated content for minor")
|
|
|
|
var result publishResult
|
|
|
|
err := owner.Execute(publishMutation, map[string]any{
|
|
"input": map[string]any{
|
|
"minor": true,
|
|
"documentId": docID,
|
|
"changelog": "Minor release",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "PUBLISHED", result.PublishDocument.DocumentVersion.Status)
|
|
assert.Nil(t, result.PublishDocument.ApprovalQuorum)
|
|
})
|
|
|
|
t.Run("minor publish with approver_ids is rejected", func(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
docID, _ := createTestDocument(t, owner)
|
|
publishMajorDocumentVersion(t, owner, docID)
|
|
updateDocumentContent(t, owner, docID, "Updated content for rejected minor")
|
|
|
|
_, err := owner.Do(publishMutation, map[string]any{
|
|
"input": map[string]any{
|
|
"minor": true,
|
|
"documentId": docID,
|
|
"approverIds": []string{getOwnerProfileID(t, owner)},
|
|
"changelog": "Minor release",
|
|
},
|
|
})
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func TestDocument_DefaultApprovers(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
approverID := getOwnerProfileID(t, owner)
|
|
|
|
t.Run("create document with default approvers", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var result struct {
|
|
CreateDocument struct {
|
|
DocumentEdge struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
DefaultApprovers []struct {
|
|
ID string `json:"id"`
|
|
} `json:"defaultApprovers"`
|
|
} `json:"node"`
|
|
} `json:"documentEdge"`
|
|
} `json:"createDocument"`
|
|
}
|
|
|
|
err := owner.Execute(`
|
|
mutation($input: CreateDocumentInput!) {
|
|
createDocument(input: $input) {
|
|
documentEdge {
|
|
node {
|
|
id
|
|
defaultApprovers { id }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": owner.GetOrganizationID().String(),
|
|
"title": "Doc With Approvers",
|
|
"content": testutil.ProseMirrorTextDoc("Content"),
|
|
"documentType": "POLICY",
|
|
"classification": "INTERNAL",
|
|
"defaultApproverIds": []string{approverID},
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
assert.Len(t, result.CreateDocument.DocumentEdge.Node.DefaultApprovers, 1)
|
|
assert.Equal(t, approverID, result.CreateDocument.DocumentEdge.Node.DefaultApprovers[0].ID)
|
|
})
|
|
|
|
t.Run("update document with default approvers", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
|
|
var result struct {
|
|
UpdateDocument struct {
|
|
Document struct {
|
|
ID string `json:"id"`
|
|
DefaultApprovers []struct {
|
|
ID string `json:"id"`
|
|
} `json:"defaultApprovers"`
|
|
} `json:"document"`
|
|
} `json:"updateDocument"`
|
|
}
|
|
|
|
err := owner.Execute(`
|
|
mutation($input: UpdateDocumentInput!) {
|
|
updateDocument(input: $input) {
|
|
document {
|
|
id
|
|
defaultApprovers { id }
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"id": docID,
|
|
"defaultApproverIds": []string{approverID},
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
assert.Len(t, result.UpdateDocument.Document.DefaultApprovers, 1)
|
|
assert.Equal(t, approverID, result.UpdateDocument.Document.DefaultApprovers[0].ID)
|
|
|
|
// Clear approvers
|
|
err = owner.Execute(`
|
|
mutation($input: UpdateDocumentInput!) {
|
|
updateDocument(input: $input) {
|
|
document {
|
|
id
|
|
defaultApprovers { id }
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"id": docID,
|
|
"defaultApproverIds": []string{},
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
assert.Empty(t, result.UpdateDocument.Document.DefaultApprovers)
|
|
})
|
|
|
|
t.Run("major publish with explicit approvers updates default approvers, even when empty", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
docID := createTestDocumentWithApprovers(t, owner, []string{approverID})
|
|
|
|
var result struct {
|
|
PublishDocument struct {
|
|
Document struct {
|
|
DefaultApprovers []struct {
|
|
ID string `json:"id"`
|
|
} `json:"defaultApprovers"`
|
|
} `json:"document"`
|
|
DocumentVersion struct {
|
|
Status string `json:"status"`
|
|
} `json:"documentVersion"`
|
|
} `json:"publishDocument"`
|
|
}
|
|
|
|
err := owner.Execute(`
|
|
mutation($input: PublishDocumentInput!) {
|
|
publishDocument(input: $input) {
|
|
document { defaultApprovers { id } }
|
|
documentVersion { status }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"minor": false,
|
|
"documentId": docID,
|
|
"approverIds": []string{},
|
|
"changelog": "Direct publish clearing approvers",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "PUBLISHED", result.PublishDocument.DocumentVersion.Status)
|
|
assert.Empty(t, result.PublishDocument.Document.DefaultApprovers)
|
|
})
|
|
}
|
|
|
|
func TestDocumentVersion_DeleteDraft(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
const query = `
|
|
mutation DeleteDocumentDraft($input: DeleteDocumentDraftInput!) {
|
|
deleteDocumentDraft(input: $input) {
|
|
document {
|
|
id
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
t.Run(
|
|
"delete draft after publishing",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
approveTestDocument(t, owner, docID)
|
|
|
|
// Create a draft by updating content
|
|
updateQuery := `
|
|
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
|
updateDocument(input: $input) {
|
|
document { id }
|
|
documentVersion { id status }
|
|
}
|
|
}
|
|
`
|
|
|
|
var updateResult struct {
|
|
UpdateDocument struct {
|
|
Document struct {
|
|
ID string `json:"id"`
|
|
} `json:"document"`
|
|
DocumentVersion *struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
} `json:"documentVersion"`
|
|
} `json:"updateDocument"`
|
|
}
|
|
|
|
err := owner.Execute(updateQuery, map[string]any{
|
|
"input": map[string]any{
|
|
"id": docID,
|
|
"content": testutil.ProseMirrorTextDoc("Draft content"),
|
|
},
|
|
}, &updateResult)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, updateResult.UpdateDocument.DocumentVersion)
|
|
assert.Equal(t, "DRAFT", updateResult.UpdateDocument.DocumentVersion.Status)
|
|
|
|
// Now delete the draft
|
|
var result struct {
|
|
DeleteDocumentDraft struct {
|
|
Document struct {
|
|
ID string `json:"id"`
|
|
} `json:"document"`
|
|
} `json:"deleteDocumentDraft"`
|
|
}
|
|
|
|
err = owner.Execute(query, map[string]any{
|
|
"input": map[string]any{"documentId": docID},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, docID, result.DeleteDocumentDraft.Document.ID)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"cannot delete initial v0.1 draft",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
|
|
var result struct{}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{"documentId": docID},
|
|
}, &result)
|
|
require.Error(t, err)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"cannot delete when latest is published",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
approveTestDocument(t, owner, docID)
|
|
|
|
var result struct{}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{"documentId": docID},
|
|
}, &result)
|
|
require.Error(t, err)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"viewer cannot delete draft",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
approveTestDocument(t, owner, docID)
|
|
|
|
// Create a draft
|
|
updateQuery := `
|
|
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
|
updateDocument(input: $input) {
|
|
document { id }
|
|
documentVersion { id }
|
|
}
|
|
}
|
|
`
|
|
|
|
var updateResult struct {
|
|
UpdateDocument struct {
|
|
Document struct {
|
|
ID string `json:"id"`
|
|
} `json:"document"`
|
|
DocumentVersion *struct {
|
|
ID string `json:"id"`
|
|
} `json:"documentVersion"`
|
|
} `json:"updateDocument"`
|
|
}
|
|
|
|
err := owner.Execute(updateQuery, map[string]any{
|
|
"input": map[string]any{
|
|
"id": docID,
|
|
"content": testutil.ProseMirrorTextDoc("Draft content"),
|
|
},
|
|
}, &updateResult)
|
|
require.NoError(t, err)
|
|
|
|
var result struct{}
|
|
|
|
err = viewer.Execute(query, map[string]any{
|
|
"input": map[string]any{"documentId": docID},
|
|
}, &result)
|
|
testutil.RequireForbiddenError(t, err, "viewer should not be able to delete document draft")
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestDocumentVersion_ExportPDFSignatures(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
t.Run(
|
|
"can export draft with signatures",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, docVersionID := createTestDocument(t, owner)
|
|
|
|
var result struct {
|
|
ExportDocumentVersionPDF struct {
|
|
Data string `json:"data"`
|
|
} `json:"exportDocumentVersionPDF"`
|
|
}
|
|
|
|
err := owner.Execute(`
|
|
mutation ExportPDF($input: ExportDocumentVersionPDFInput!) {
|
|
exportDocumentVersionPDF(input: $input) {
|
|
data
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"documentVersionId": docVersionID,
|
|
"withWatermark": false,
|
|
"withSignatures": true,
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, result.ExportDocumentVersionPDF.Data)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"can export published with signatures",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
approveTestDocument(t, owner, docID)
|
|
|
|
var versionResult struct {
|
|
Node struct {
|
|
Versions struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"versions"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := owner.Execute(`
|
|
query GetVersions($id: ID!) {
|
|
node(id: $id) {
|
|
... on Document {
|
|
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
|
edges { node { id } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{"id": docID}, &versionResult)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, versionResult.Node.Versions.Edges)
|
|
|
|
publishedVersionID := versionResult.Node.Versions.Edges[0].Node.ID
|
|
|
|
var result struct {
|
|
ExportDocumentVersionPDF struct {
|
|
Data string `json:"data"`
|
|
} `json:"exportDocumentVersionPDF"`
|
|
}
|
|
|
|
err = owner.Execute(`
|
|
mutation ExportPDF($input: ExportDocumentVersionPDFInput!) {
|
|
exportDocumentVersionPDF(input: $input) {
|
|
data
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"documentVersionId": publishedVersionID,
|
|
"withWatermark": false,
|
|
"withSignatures": true,
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, result.ExportDocumentVersionPDF.Data)
|
|
},
|
|
)
|
|
}
|
|
|
|
// TestDocumentVersion_MajorPublishCancelsSignatureRequests verifies that
|
|
// publishing a new major version directly (no approvers) cancels the still
|
|
// pending signature requests attached to the previous major version.
|
|
func TestDocumentVersion_MajorPublishCancelsSignatureRequests(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
signer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
|
|
v1ID := publishMajorDocumentVersion(t, owner, docID)
|
|
requestDocumentSignature(t, owner, v1ID, signer.GetProfileID().String())
|
|
assertRequestedSignatureCount(t, owner, v1ID, 1)
|
|
|
|
// A new major supersedes the previous one, so its pending request is cancelled.
|
|
updateDocumentContent(t, owner, docID, "Updated content for v2")
|
|
v2ID := publishMajorDocumentVersion(t, owner, docID)
|
|
|
|
assertRequestedSignatureCount(t, owner, v1ID, 0)
|
|
assertRequestedSignatureCount(t, owner, v2ID, 0)
|
|
}
|
|
|
|
// TestDocumentVersion_MinorPublishKeepsSignatureRequests verifies that
|
|
// publishing a minor version stays within the same major and therefore keeps
|
|
// pending signature requests intact.
|
|
func TestDocumentVersion_MinorPublishKeepsSignatureRequests(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
signer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
|
|
v1ID := publishMajorDocumentVersion(t, owner, docID)
|
|
requestDocumentSignature(t, owner, v1ID, signer.GetProfileID().String())
|
|
assertRequestedSignatureCount(t, owner, v1ID, 1)
|
|
|
|
// A minor bump keeps the same major; the request must survive.
|
|
updateDocumentContent(t, owner, docID, "Updated content for 1.1")
|
|
publishMinorDocumentVersion(t, owner, docID)
|
|
|
|
assertRequestedSignatureCount(t, owner, v1ID, 1)
|
|
}
|
|
|
|
// TestDocumentVersion_MajorApprovalPublishCancelsSignatureRequests verifies
|
|
// that on the approval path the pending signature requests from the previous
|
|
// major are cancelled only once the quorum succeeds and the new major is
|
|
// actually published, not while the approval is still pending.
|
|
func TestDocumentVersion_MajorApprovalPublishCancelsSignatureRequests(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
signer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
approveTestDocument(t, owner, docID)
|
|
|
|
v1ID := latestDocumentVersionID(t, owner, docID)
|
|
requestDocumentSignature(t, owner, v1ID, signer.GetProfileID().String())
|
|
assertRequestedSignatureCount(t, owner, v1ID, 1)
|
|
|
|
// Open a major approval for v2. While the quorum is pending the request
|
|
// from the previous major must remain untouched.
|
|
updateDocumentContent(t, owner, docID, "Updated content for v2")
|
|
requestDocumentApproval(t, owner, docID, []string{getOwnerProfileID(t, owner)})
|
|
assertRequestedSignatureCount(t, owner, v1ID, 1)
|
|
|
|
// Once the quorum succeeds and v2.0 is published, the request is cancelled.
|
|
approveLatestDocumentVersion(t, owner, docID)
|
|
assertRequestedSignatureCount(t, owner, v1ID, 0)
|
|
}
|
|
|
|
// TestDocumentVersion_RequestSignatureIsIdempotentWithinVersion verifies that
|
|
// requesting a signature twice for the same signatory on the same version does
|
|
// not create a duplicate signature row.
|
|
func TestDocumentVersion_RequestSignatureIsIdempotentWithinVersion(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
signer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
signerID := signer.GetProfileID().String()
|
|
|
|
v1ID := publishMajorDocumentVersion(t, owner, docID)
|
|
requestDocumentSignature(t, owner, v1ID, signerID)
|
|
requestDocumentSignature(t, owner, v1ID, signerID)
|
|
|
|
assertRequestedSignatureCount(t, owner, v1ID, 1)
|
|
}
|
|
|
|
// TestDocumentVersion_RequestSignatureDeduplicatesAcrossMinors verifies that
|
|
// re-requesting a signature on a newer minor of the same major reuses the
|
|
// signatory's existing signature instead of creating a second one, so the
|
|
// person is not listed twice in the major's export.
|
|
func TestDocumentVersion_RequestSignatureDeduplicatesAcrossMinors(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
signer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
signerID := signer.GetProfileID().String()
|
|
|
|
v10ID := publishMajorDocumentVersion(t, owner, docID)
|
|
requestDocumentSignature(t, owner, v10ID, signerID)
|
|
assertRequestedSignatureCount(t, owner, v10ID, 1)
|
|
|
|
// A minor bump stays in the same major; the request stays on v1.0.
|
|
updateDocumentContent(t, owner, docID, "Updated content for 1.1")
|
|
v11ID := publishMinorDocumentVersion(t, owner, docID)
|
|
|
|
// Re-requesting on the newer minor must reuse the existing signature
|
|
// instead of inserting a duplicate within the same major. The signature
|
|
// query aggregates across every minor of the major, so a duplicate would
|
|
// surface as a major-wide REQUESTED count of 2; the fix keeps it at 1.
|
|
requestDocumentSignature(t, owner, v11ID, signerID)
|
|
|
|
assertRequestedSignatureCount(t, owner, v10ID, 1)
|
|
assertRequestedSignatureCount(t, owner, v11ID, 1)
|
|
}
|
|
|
|
// requestedSignatureVersions returns a map of signature node ID -> the document
|
|
// version ID it is currently attached to, for the REQUESTED signatures visible
|
|
// from versionID (the signatures connection aggregates across every minor of
|
|
// the major).
|
|
func requestedSignatureVersions(
|
|
t *testing.T,
|
|
owner *testutil.Client,
|
|
versionID string,
|
|
) map[string]string {
|
|
t.Helper()
|
|
|
|
var result struct {
|
|
Node struct {
|
|
Signatures struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
State string `json:"state"`
|
|
DocumentVersion struct {
|
|
ID string `json:"id"`
|
|
} `json:"documentVersion"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"signatures"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := owner.Execute(`
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on DocumentVersion {
|
|
signatures(first: 50, filter: { states: [REQUESTED] }) {
|
|
edges { node { id state documentVersion { id } } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{"id": versionID}, &result)
|
|
require.NoError(t, err)
|
|
|
|
out := make(map[string]string, len(result.Node.Signatures.Edges))
|
|
for _, edge := range result.Node.Signatures.Edges {
|
|
out[edge.Node.ID] = edge.Node.DocumentVersion.ID
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// TestDocumentVersion_MinorPublishMovesSignatureRequestsToNewVersion verifies
|
|
// that publishing a new minor version carries the still-pending signature
|
|
// requests from the previous version onto the newly published version. The same
|
|
// signature row is reused (its ID is preserved), so its notification schedule
|
|
// (count and last-notified time) is kept intact rather than reset.
|
|
func TestDocumentVersion_MinorPublishMovesSignatureRequestsToNewVersion(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
signer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
|
|
v10ID := publishMajorDocumentVersion(t, owner, docID)
|
|
requestDocumentSignature(t, owner, v10ID, signer.GetProfileID().String())
|
|
|
|
// The pending request starts attached to v1.0.
|
|
before := requestedSignatureVersions(t, owner, v10ID)
|
|
require.Len(t, before, 1)
|
|
|
|
var signatureID string
|
|
for id, versionID := range before {
|
|
signatureID = id
|
|
|
|
assert.Equal(t, v10ID, versionID, "the request must start on v1.0")
|
|
}
|
|
|
|
// A minor bump publishes v1.1 within the same major.
|
|
updateDocumentContent(t, owner, docID, "Updated content for 1.1")
|
|
v11ID := publishMinorDocumentVersion(t, owner, docID)
|
|
require.NotEqual(t, v10ID, v11ID)
|
|
|
|
// The same request row is now attached to the newly published v1.1.
|
|
after := requestedSignatureVersions(t, owner, v11ID)
|
|
require.Len(t, after, 1)
|
|
|
|
movedVersionID, ok := after[signatureID]
|
|
require.True(t, ok, "the signature request must keep its identity across the minor publish")
|
|
assert.Equal(t, v11ID, movedVersionID, "the pending request must move to the newly published minor version")
|
|
}
|
|
|
|
// signDocumentVersion signs the version as the authenticated client and returns
|
|
// the resulting signature node's id, state and signing time.
|
|
func signDocumentVersion(t *testing.T, signer *testutil.Client, versionID string) (id, state, signedAt string) {
|
|
t.Helper()
|
|
|
|
var result struct {
|
|
SignDocument struct {
|
|
DocumentVersionSignature struct {
|
|
ID string `json:"id"`
|
|
State string `json:"state"`
|
|
SignedAt string `json:"signedAt"`
|
|
} `json:"documentVersionSignature"`
|
|
} `json:"signDocument"`
|
|
}
|
|
|
|
err := signer.Execute(`
|
|
mutation($input: SignDocumentInput!) {
|
|
signDocument(input: $input) {
|
|
documentVersionSignature { id state signedAt }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"documentVersionId": versionID,
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
return result.SignDocument.DocumentVersionSignature.ID,
|
|
result.SignDocument.DocumentVersionSignature.State,
|
|
result.SignDocument.DocumentVersionSignature.SignedAt
|
|
}
|
|
|
|
func archiveDocument(t *testing.T, owner *testutil.Client, docID string) {
|
|
t.Helper()
|
|
|
|
_, err := owner.Do(`
|
|
mutation($input: ArchiveDocumentInput!) {
|
|
archiveDocument(input: $input) {
|
|
document { id }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"documentId": docID,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func unarchiveDocument(t *testing.T, owner *testutil.Client, docID string) {
|
|
t.Helper()
|
|
|
|
_, err := owner.Do(`
|
|
mutation($input: UnarchiveDocumentInput!) {
|
|
unarchiveDocument(input: $input) {
|
|
document { id status }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"documentId": docID,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// signDocumentVersionMutation is the raw mutation used by the negative-path
|
|
// signing tests so they can assert the request is rejected.
|
|
const signDocumentVersionMutation = `
|
|
mutation($input: SignDocumentInput!) {
|
|
signDocument(input: $input) {
|
|
documentVersionSignature { id state }
|
|
}
|
|
}
|
|
`
|
|
|
|
// TestDocumentVersion_SignDocument verifies that a requested signature can be
|
|
// signed by the signatory, transitioning REQUESTED -> SIGNED and recording the
|
|
// signing time. Signing exercises the electronic-signature integration end to
|
|
// end: PDF export, upload, and esign create-and-accept.
|
|
func TestDocumentVersion_SignDocument(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
approveTestDocument(t, owner, docID)
|
|
|
|
publishedVersionID := latestDocumentVersionID(t, owner, docID)
|
|
ownerProfileID := owner.GetProfileID().String()
|
|
|
|
requestDocumentSignature(t, owner, publishedVersionID, ownerProfileID)
|
|
|
|
id, state, signedAt := signDocumentVersion(t, owner, publishedVersionID)
|
|
|
|
assert.NotEmpty(t, id)
|
|
assert.Equal(t, "SIGNED", state)
|
|
assert.NotEmpty(t, signedAt)
|
|
}
|
|
|
|
// TestDocumentVersion_SignDocumentWithoutRequestFails verifies that a version
|
|
// cannot be signed unless a signature was first requested for the signatory.
|
|
func TestDocumentVersion_SignDocumentWithoutRequestFails(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
approveTestDocument(t, owner, docID)
|
|
|
|
publishedVersionID := latestDocumentVersionID(t, owner, docID)
|
|
|
|
_ = owner.ExecuteShouldFail(signDocumentVersionMutation, map[string]any{
|
|
"input": map[string]any{
|
|
"documentVersionId": publishedVersionID,
|
|
},
|
|
})
|
|
}
|
|
|
|
// TestDocumentVersion_SignDocumentTwiceFails verifies that an already-signed
|
|
// signature cannot be signed again.
|
|
func TestDocumentVersion_SignDocumentTwiceFails(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
approveTestDocument(t, owner, docID)
|
|
|
|
publishedVersionID := latestDocumentVersionID(t, owner, docID)
|
|
ownerProfileID := owner.GetProfileID().String()
|
|
|
|
requestDocumentSignature(t, owner, publishedVersionID, ownerProfileID)
|
|
signDocumentVersion(t, owner, publishedVersionID)
|
|
|
|
_ = owner.ExecuteShouldFail(signDocumentVersionMutation, map[string]any{
|
|
"input": map[string]any{
|
|
"documentVersionId": publishedVersionID,
|
|
},
|
|
})
|
|
}
|
|
|
|
func TestDocumentVersion_ArchiveVoidsPendingQuorum(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
requestDocumentApproval(t, owner, docID, []string{getOwnerProfileID(t, owner)})
|
|
|
|
archiveDocument(t, owner, docID)
|
|
|
|
var result struct {
|
|
Node struct {
|
|
Status string `json:"status"`
|
|
Versions struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
Status string `json:"status"`
|
|
Major int `json:"major"`
|
|
Minor int `json:"minor"`
|
|
ApprovalQuorums struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
Status string `json:"status"`
|
|
Decisions struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
State string `json:"state"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"decisions"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"approvalQuorums"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"versions"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := owner.Execute(`
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Document {
|
|
status
|
|
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
|
edges {
|
|
node {
|
|
status
|
|
major
|
|
minor
|
|
approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
|
edges {
|
|
node {
|
|
status
|
|
decisions(first: 10) {
|
|
edges { node { state } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{"id": docID}, &result)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, result.Node.Versions.Edges)
|
|
|
|
version := result.Node.Versions.Edges[0].Node
|
|
assert.Equal(t, "ARCHIVED", result.Node.Status)
|
|
assert.Equal(t, "DRAFT", version.Status)
|
|
assert.Equal(t, 0, version.Major)
|
|
assert.Equal(t, 1, version.Minor)
|
|
require.NotEmpty(t, version.ApprovalQuorums.Edges)
|
|
assert.Equal(t, "VOIDED", version.ApprovalQuorums.Edges[0].Node.Status)
|
|
|
|
for _, edge := range version.ApprovalQuorums.Edges[0].Node.Decisions.Edges {
|
|
assert.Equal(t, "VOIDED", edge.Node.State)
|
|
}
|
|
}
|
|
|
|
func TestDocumentVersion_ArchiveCancelsPendingSignatures(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
approveTestDocument(t, owner, docID)
|
|
|
|
publishedVersionID := latestDocumentVersionID(t, owner, docID)
|
|
requestDocumentSignature(t, owner, publishedVersionID, getOwnerProfileID(t, owner))
|
|
|
|
archiveDocument(t, owner, docID)
|
|
|
|
var result struct {
|
|
Node struct {
|
|
Signatures struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
State string `json:"state"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"signatures"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := owner.Execute(`
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on DocumentVersion {
|
|
signatures(first: 10) {
|
|
edges { node { state } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{"id": publishedVersionID}, &result)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, result.Node.Signatures.Edges)
|
|
}
|
|
|
|
func TestDocumentVersion_UnarchiveRestoresEditableDraft(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
requestDocumentApproval(t, owner, docID, []string{getOwnerProfileID(t, owner)})
|
|
|
|
archiveDocument(t, owner, docID)
|
|
unarchiveDocument(t, owner, docID)
|
|
|
|
var result struct {
|
|
Node struct {
|
|
Status string `json:"status"`
|
|
Versions struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
Status string `json:"status"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"versions"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := owner.Execute(`
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Document {
|
|
status
|
|
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
|
edges { node { status } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{"id": docID}, &result)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, result.Node.Versions.Edges)
|
|
|
|
assert.Equal(t, "ACTIVE", result.Node.Status)
|
|
assert.Equal(t, "DRAFT", result.Node.Versions.Edges[0].Node.Status)
|
|
|
|
updateDocumentContent(t, owner, docID, "Updated after unarchive")
|
|
}
|
|
|
|
// TestDocumentVersion_SignArchivedDocumentFails verifies that a document
|
|
// archived after its signature was requested can no longer be signed. This
|
|
// guards the archived/published preconditions that are re-validated inside the
|
|
// signing transaction so a state change between request and sign is honored.
|
|
func TestDocumentVersion_SignArchivedDocumentFails(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
docID, _ := createTestDocument(t, owner)
|
|
approveTestDocument(t, owner, docID)
|
|
|
|
publishedVersionID := latestDocumentVersionID(t, owner, docID)
|
|
ownerProfileID := owner.GetProfileID().String()
|
|
|
|
requestDocumentSignature(t, owner, publishedVersionID, ownerProfileID)
|
|
|
|
archiveDocument(t, owner, docID)
|
|
|
|
var signatureResult struct {
|
|
Node struct {
|
|
Signatures struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"signatures"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := owner.Execute(`
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on DocumentVersion {
|
|
signatures(first: 10) {
|
|
edges { node { id } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{"id": publishedVersionID}, &signatureResult)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, signatureResult.Node.Signatures.Edges)
|
|
|
|
_ = owner.ExecuteShouldFail(signDocumentVersionMutation, map[string]any{
|
|
"input": map[string]any{
|
|
"documentVersionId": publishedVersionID,
|
|
},
|
|
})
|
|
}
|
|
|
|
func TestDocumentVersion_TenantIsolation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
org1Owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
docID := factory.NewDocument(org1Owner).WithTitle("Org1 Document for Version Isolation").Create()
|
|
versionID := latestDocumentVersionID(t, org1Owner, docID)
|
|
|
|
t.Run("cannot read documentVersion from another organization", func(t *testing.T) {
|
|
query := `
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on DocumentVersion {
|
|
id
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
Node *struct {
|
|
ID string `json:"id"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := org2Owner.Execute(query, map[string]any{"id": versionID}, &result)
|
|
testutil.AssertNodeNotAccessible(t, err, result.Node == nil, "documentVersion")
|
|
})
|
|
}
|