diff --git a/apps/console/src/pages/organizations/documents/DocumentLayout.tsx b/apps/console/src/pages/organizations/documents/DocumentLayout.tsx index d8760c2d9..ec1e5b077 100644 --- a/apps/console/src/pages/organizations/documents/DocumentLayout.tsx +++ b/apps/console/src/pages/organizations/documents/DocumentLayout.tsx @@ -225,7 +225,7 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery )} @@ -242,7 +242,6 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery diff --git a/apps/console/src/pages/organizations/documents/_components/DocumentDetailsCard.tsx b/apps/console/src/pages/organizations/documents/_components/DocumentDetailsCard.tsx index c5e0ce991..f314fb3e3 100644 --- a/apps/console/src/pages/organizations/documents/_components/DocumentDetailsCard.tsx +++ b/apps/console/src/pages/organizations/documents/_components/DocumentDetailsCard.tsx @@ -124,7 +124,6 @@ export function DocumentDetailsCard(props: { documentFragmentRef: DocumentDetailsCard_documentFragment$key; versionFragmentRef: DocumentDetailsCard_versionFragment$key; isEditable: boolean; - isGenerated?: boolean; isLatestVersion?: boolean; onDocumentUpdated: () => void; }) { @@ -132,7 +131,6 @@ export function DocumentDetailsCard(props: { documentFragmentRef, versionFragmentRef, isEditable, - isGenerated = false, isLatestVersion = true, onDocumentUpdated, } = props; @@ -149,7 +147,7 @@ export function DocumentDetailsCard(props: { const version = useFragment(versionFragment, versionFragmentRef); const canEdit = document.canUpdate && isEditable; - const canEditVersionFields = canEdit && !isGenerated; + const canEditVersionFields = canEdit; const canEditApprovers = document.canUpdate && isLatestVersion; const { control, handleSubmit, reset } = useFormWithSchema( diff --git a/e2e/console/statement_of_applicability_test.go b/e2e/console/statement_of_applicability_test.go index 9afe99be8..ff9c631b0 100644 --- a/e2e/console/statement_of_applicability_test.go +++ b/e2e/console/statement_of_applicability_test.go @@ -440,6 +440,275 @@ func TestStatementOfApplicability_CreateDocument_RBAC(t *testing.T) { ) } +func TestStatementOfApplicability_UpdateDocumentMetadata(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + publishSOADocument := func(t *testing.T) (documentID, documentVersionID string) { + t.Helper() + + frameworkID := factory.NewFramework(owner).Create() + controlID := factory.NewControl(owner, frameworkID).Create() + + soaID := factory.NewStatementOfApplicability(owner).Create() + factory.CreateApplicabilityStatement(owner, soaID, controlID, true, nil) + + const publishQuery = ` + mutation($input: PublishStatementOfApplicabilityInput!) { + publishStatementOfApplicability(input: $input) { + documentEdge { node { id } } + documentVersionEdge { node { id } } + } + } + ` + + var publishResult struct { + PublishStatementOfApplicability struct { + DocumentEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"documentEdge"` + DocumentVersionEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"documentVersionEdge"` + } `json:"publishStatementOfApplicability"` + } + + err := owner.Execute( + publishQuery, + map[string]any{ + "input": map[string]any{ + "minor": false, + "statementOfApplicabilityId": soaID, + }, + }, + &publishResult, + ) + require.NoError(t, err) + + return publishResult.PublishStatementOfApplicability.DocumentEdge.Node.ID, + publishResult.PublishStatementOfApplicability.DocumentVersionEdge.Node.ID + } + + const updateQuery = ` + mutation($input: UpdateDocumentInput!) { + updateDocument(input: $input) { + document { id writeMode } + documentVersion { + id + title + documentType + classification + status + } + } + } + ` + + type updateResult struct { + UpdateDocument struct { + Document struct { + ID string `json:"id"` + WriteMode string `json:"writeMode"` + } `json:"document"` + DocumentVersion *struct { + ID string `json:"id"` + Title string `json:"title"` + DocumentType string `json:"documentType"` + Classification string `json:"classification"` + Status string `json:"status"` + } `json:"documentVersion"` + } `json:"updateDocument"` + } + + t.Run( + "editing title, type, and classification on generated document creates a draft", + func(t *testing.T) { + t.Parallel() + + documentID, publishedVersionID := publishSOADocument(t) + + var result updateResult + err := owner.Execute( + updateQuery, + map[string]any{ + "input": map[string]any{ + "id": documentID, + "title": "Renamed SOA", + "documentType": "POLICY", + "classification": "INTERNAL", + }, + }, + &result, + ) + require.NoError(t, err) + + assert.Equal(t, "GENERATED", result.UpdateDocument.Document.WriteMode) + require.NotNil(t, result.UpdateDocument.DocumentVersion) + assert.NotEqual(t, publishedVersionID, result.UpdateDocument.DocumentVersion.ID, + "should create a new draft, not update the published version") + assert.Equal(t, "DRAFT", result.UpdateDocument.DocumentVersion.Status) + assert.Equal(t, "Renamed SOA", result.UpdateDocument.DocumentVersion.Title) + assert.Equal(t, "POLICY", result.UpdateDocument.DocumentVersion.DocumentType) + assert.Equal(t, "INTERNAL", result.UpdateDocument.DocumentVersion.Classification) + }, + ) + + t.Run( + "cannot edit content of a generated document", + func(t *testing.T) { + t.Parallel() + + documentID, _ := publishSOADocument(t) + + err := owner.ExecuteShouldFail( + updateQuery, + map[string]any{ + "input": map[string]any{ + "id": documentID, + "content": `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hand-edited"}]}]}`, + }, + }, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "generated") + }, + ) + + t.Run( + "re-publish preserves edited title, type, and classification", + func(t *testing.T) { + t.Parallel() + + frameworkID := factory.NewFramework(owner).Create() + controlID := factory.NewControl(owner, frameworkID).Create() + + soaID := factory.NewStatementOfApplicability(owner).Create() + factory.CreateApplicabilityStatement(owner, soaID, controlID, true, nil) + + const publishSOAQuery = ` + mutation($input: PublishStatementOfApplicabilityInput!) { + publishStatementOfApplicability(input: $input) { + documentEdge { node { id } } + documentVersionEdge { node { id title documentType classification major minor } } + } + } + ` + + type publishSOAResult struct { + PublishStatementOfApplicability struct { + DocumentEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"documentEdge"` + DocumentVersionEdge struct { + Node struct { + ID string `json:"id"` + Title string `json:"title"` + DocumentType string `json:"documentType"` + Classification string `json:"classification"` + Major int `json:"major"` + Minor int `json:"minor"` + } `json:"node"` + } `json:"documentVersionEdge"` + } `json:"publishStatementOfApplicability"` + } + + var firstPublish publishSOAResult + err := owner.Execute( + publishSOAQuery, + map[string]any{ + "input": map[string]any{ + "minor": false, + "statementOfApplicabilityId": soaID, + }, + }, + &firstPublish, + ) + require.NoError(t, err) + documentID := firstPublish.PublishStatementOfApplicability.DocumentEdge.Node.ID + require.Equal(t, "STATEMENT_OF_APPLICABILITY", firstPublish.PublishStatementOfApplicability.DocumentVersionEdge.Node.DocumentType) + require.Equal(t, "CONFIDENTIAL", firstPublish.PublishStatementOfApplicability.DocumentVersionEdge.Node.Classification) + + var editResult updateResult + err = owner.Execute( + updateQuery, + map[string]any{ + "input": map[string]any{ + "id": documentID, + "title": "Custom SOA Name", + "documentType": "POLICY", + "classification": "INTERNAL", + }, + }, + &editResult, + ) + require.NoError(t, err) + require.NotNil(t, editResult.UpdateDocument.DocumentVersion) + require.Equal(t, "DRAFT", editResult.UpdateDocument.DocumentVersion.Status) + + const publishDraftQuery = ` + mutation($input: PublishDocumentInput!) { + publishDocument(input: $input) { + documentVersion { id title documentType classification major minor status } + } + } + ` + var publishDraft struct { + PublishDocument struct { + DocumentVersion struct { + ID string `json:"id"` + Title string `json:"title"` + DocumentType string `json:"documentType"` + Classification string `json:"classification"` + Major int `json:"major"` + Minor int `json:"minor"` + Status string `json:"status"` + } `json:"documentVersion"` + } `json:"publishDocument"` + } + err = owner.Execute( + publishDraftQuery, + map[string]any{ + "input": map[string]any{ + "documentId": documentID, + "minor": true, + "changelog": "rename", + }, + }, + &publishDraft, + ) + require.NoError(t, err) + require.Equal(t, "PUBLISHED", publishDraft.PublishDocument.DocumentVersion.Status) + require.Equal(t, "Custom SOA Name", publishDraft.PublishDocument.DocumentVersion.Title) + require.Equal(t, "POLICY", publishDraft.PublishDocument.DocumentVersion.DocumentType) + require.Equal(t, "INTERNAL", publishDraft.PublishDocument.DocumentVersion.Classification) + + var rePublish publishSOAResult + err = owner.Execute( + publishSOAQuery, + map[string]any{ + "input": map[string]any{ + "minor": true, + "statementOfApplicabilityId": soaID, + }, + }, + &rePublish, + ) + require.NoError(t, err) + node := rePublish.PublishStatementOfApplicability.DocumentVersionEdge.Node + assert.Equal(t, "Custom SOA Name", node.Title, "re-publish should preserve edited title") + assert.Equal(t, "POLICY", node.DocumentType, "re-publish should preserve edited type") + assert.Equal(t, "INTERNAL", node.Classification, "re-publish should preserve edited classification") + }, + ) +} + func TestStatementOfApplicability_TenantIsolation(t *testing.T) { t.Parallel() diff --git a/pkg/cmd/document/update/update.go b/pkg/cmd/document/update/update.go index a0d0345f8..3a1d57a17 100644 --- a/pkg/cmd/document/update/update.go +++ b/pkg/cmd/document/update/update.go @@ -106,7 +106,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { if err := cmdutil.ValidateEnum( "document-type", flagDocumentType, - []string{"OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE"}, + []string{"OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE", "STATEMENT_OF_APPLICABILITY"}, ); err != nil { return err } @@ -174,7 +174,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { cmd.Flags().StringVar(&flagTitle, "title", "", "Document title") cmd.Flags().StringVar(&flagContent, "content", "", "Document content") - cmd.Flags().StringVar(&flagDocumentType, "document-type", "", "Document type: OTHER, GOVERNANCE, POLICY, PROCEDURE, PLAN, REGISTER, RECORD, REPORT, TEMPLATE") + cmd.Flags().StringVar(&flagDocumentType, "document-type", "", "Document type: OTHER, GOVERNANCE, POLICY, PROCEDURE, PLAN, REGISTER, RECORD, REPORT, TEMPLATE, STATEMENT_OF_APPLICABILITY") cmd.Flags().StringVar(&flagClassification, "classification", "", "Classification: PUBLIC, INTERNAL, CONFIDENTIAL, SECRET") cmd.Flags().StringVar(&flagTrustCenterVisibility, "trust-center-visibility", "", "Trust center visibility: NONE, PRIVATE, PUBLIC") diff --git a/pkg/probo/document_service.go b/pkg/probo/document_service.go index cea60573d..5e11cac51 100644 --- a/pkg/probo/document_service.go +++ b/pkg/probo/document_service.go @@ -267,7 +267,7 @@ func (e ErrDocumentGenerated) Error() string { } func (e ErrDocumentVersionGenerated) Error() string { - return "cannot edit a generated document version" + return "cannot edit content of a generated document version" } func (e ErrDocumentVersionSignatureAlreadySigned) Error() string { @@ -1800,7 +1800,7 @@ func (s *DocumentService) Update( hasVersionChanges := req.Title != nil || req.Content != nil || req.Classification != nil || req.DocumentType != nil - if hasVersionChanges && document.WriteMode == coredata.DocumentWriteModeGenerated { + if req.Content != nil && document.WriteMode == coredata.DocumentWriteModeGenerated { return &ErrDocumentVersionGenerated{} } diff --git a/pkg/probo/generated_document_service.go b/pkg/probo/generated_document_service.go index febc76eca..c8115bdd7 100644 --- a/pkg/probo/generated_document_service.go +++ b/pkg/probo/generated_document_service.go @@ -2909,6 +2909,19 @@ func (s *GeneratedDocumentService) publishOrRequestApproval( minor bool, now time.Time, ) error { + previousVersion := &coredata.DocumentVersion{} + err := previousVersion.LoadLatestVersion(ctx, tx, s.svc.scope, document.ID) + switch { + case err == nil: + version.Title = previousVersion.Title + version.Classification = previousVersion.Classification + version.DocumentType = previousVersion.DocumentType + case errors.Is(err, coredata.ErrResourceNotFound): + // First publish: keep the caller-provided defaults. + default: + return fmt.Errorf("cannot load previous document version: %w", err) + } + if minor { if document.CurrentPublishedMajor == nil || document.CurrentPublishedMinor == nil { return &ErrCannotPublishMinorWithoutMajor{} @@ -2936,7 +2949,14 @@ func (s *GeneratedDocumentService) publishOrRequestApproval( if err := version.Insert(ctx, tx, s.svc.scope); err != nil { if errors.Is(err, coredata.ErrResourceAlreadyExists) { - return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err) + switch previousVersion.Status { + case coredata.DocumentVersionStatusDraft: + return fmt.Errorf("a draft version exists, publish or delete it before publishing a new one: %w", err) + case coredata.DocumentVersionStatusPendingApproval: + return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err) + default: + return fmt.Errorf("a version already exists at this number: %w", err) + } } return fmt.Errorf("cannot insert document version: %w", err) } diff --git a/pkg/server/api/console/v1/document_resolvers.go b/pkg/server/api/console/v1/document_resolvers.go index 8b71a2cf7..467d3d3bf 100644 --- a/pkg/server/api/console/v1/document_resolvers.go +++ b/pkg/server/api/console/v1/document_resolvers.go @@ -864,6 +864,9 @@ func (r *mutationResolver) UpdateDocument(ctx context.Context, input types.Updat if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok { return nil, gqlutils.Conflict(ctx, errArchived) } + if errGenerated, ok := errors.AsType[*probo.ErrDocumentVersionGenerated](err); ok { + return nil, gqlutils.Conflict(ctx, errGenerated) + } if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) }