From 7cd4606278dccdb0fd28a060272250dbfb889f65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Thu, 9 Apr 2026 12:50:46 +0400 Subject: [PATCH] Validate document content length by extracted text, not JSON size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Node.TextLength() to walk the ProseMirror tree and sum actual user text. Introduce ProseMirrorDocumentMaxTextLength validator that enforces a 50k character limit on extracted text, paired with a 500k byte safety cap on the raw JSON string. Extend e2e tests for content length. Signed-off-by: Émile Ré --- e2e/console/document_test.go | 55 ++++++++++++++- pkg/probo/document_service.go | 9 ++- pkg/prosemirror/node.go | 14 ++++ pkg/prosemirror/node_test.go | 76 +++++++++++++++++++++ pkg/validator/validator_prosemirror.go | 33 +++++++++ pkg/validator/validator_prosemirror_test.go | 47 ++++++++++++- 6 files changed, 228 insertions(+), 6 deletions(-) diff --git a/e2e/console/document_test.go b/e2e/console/document_test.go index cce37369e..90f7abf59 100644 --- a/e2e/console/document_test.go +++ b/e2e/console/document_test.go @@ -966,7 +966,7 @@ func TestDocument_MaxLength_Validation(t *testing.T) { longTitle := strings.Repeat("a", 1001) - t.Run("create", func(t *testing.T) { + t.Run("create with long title", func(t *testing.T) { query := ` mutation CreateDocument($input: CreateDocumentInput!) { createDocument(input: $input) { @@ -990,7 +990,7 @@ func TestDocument_MaxLength_Validation(t *testing.T) { assert.Contains(t, err.Error(), "title") }) - t.Run("update", func(t *testing.T) { + t.Run("update with long title", func(t *testing.T) { documentID := factory.NewDocument(owner).WithTitle("Max Length Test").Create() query := ` @@ -1010,6 +1010,57 @@ func TestDocument_MaxLength_Validation(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "title") }) + + t.Run("create with long content", func(t *testing.T) { + query := ` + mutation CreateDocument($input: CreateDocumentInput!) { + createDocument(input: $input) { + documentEdge { + node { id } + } + } + } + ` + + longContent := testutil.ProseMirrorTextDoc(strings.Repeat("a", 50_001)) + + _, err := owner.Do(query, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "title": "Content Length Test", + "content": longContent, + "documentType": "POLICY", + "classification": "INTERNAL", + }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "content") + }) + + t.Run("update version with long content", func(t *testing.T) { + docID, versionID := createTestDocument(t, owner) + require.NotEmpty(t, docID) + require.NotEmpty(t, versionID) + + query := ` + mutation UpdateDocumentVersion($input: UpdateDocumentVersionInput!) { + updateDocumentVersion(input: $input) { + documentVersion { id } + } + } + ` + + longContent := testutil.ProseMirrorTextDoc(strings.Repeat("a", 50_001)) + + _, err := owner.Do(query, map[string]any{ + "input": map[string]any{ + "documentVersionId": versionID, + "content": longContent, + }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "content") + }) } func TestDocument_Pagination(t *testing.T) { diff --git a/pkg/probo/document_service.go b/pkg/probo/document_service.go index eaeb48508..624ce95b2 100644 --- a/pkg/probo/document_service.go +++ b/pkg/probo/document_service.go @@ -110,7 +110,8 @@ type ( ) const ( - documentMaxLength = 50_000 + documentContentMaxTextLength = 50_000 + documentContentMaxJSONBytes = 500_000 ) func (cdr *CreateDocumentRequest) Validate() error { @@ -121,8 +122,9 @@ func (cdr *CreateDocumentRequest) Validate() error { v.Check( cdr.Content, "content", - validator.MaxLen(documentMaxLength), + validator.MaxLen(documentContentMaxJSONBytes), validator.ProseMirrorDocumentContent(), + validator.ProseMirrorDocumentMaxTextLength(documentContentMaxTextLength), ) v.Check(cdr.Classification, "classification", validator.Required(), validator.OneOfSlice(coredata.DocumentClassifications())) v.Check(cdr.DocumentType, "document_type", validator.Required(), validator.OneOfSlice(coredata.DocumentTypes())) @@ -149,8 +151,9 @@ func (udvr *UpdateDocumentVersionRequest) Validate() error { v.Check( udvr.Content, "content", - validator.MaxLen(documentMaxLength), + validator.MaxLen(documentContentMaxJSONBytes), validator.ProseMirrorDocumentContent(), + validator.ProseMirrorDocumentMaxTextLength(documentContentMaxTextLength), ) v.Check(udvr.DocumentType, "document_type", validator.OneOfSlice(coredata.DocumentTypes())) diff --git a/pkg/prosemirror/node.go b/pkg/prosemirror/node.go index d47392816..3f84501a5 100644 --- a/pkg/prosemirror/node.go +++ b/pkg/prosemirror/node.go @@ -168,6 +168,20 @@ func (n Node) TableCellAttrs() (TableCellAttrs, error) { return a, nil } +// TextLength returns the total length of all text content in the node tree, +// measured in bytes (consistent with Go's len on strings). Only text carried +// by leaf text nodes is counted; structural markup is excluded. +func (n Node) TextLength() int { + length := 0 + if n.Text != nil { + length += len(*n.Text) + } + for _, child := range n.Content { + length += child.TextLength() + } + return length +} + // LinkAttrs parses and returns the link attributes from a link mark. func (m Mark) LinkAttrs() (LinkAttrs, error) { var a LinkAttrs diff --git a/pkg/prosemirror/node_test.go b/pkg/prosemirror/node_test.go index a85233a19..ff3676430 100644 --- a/pkg/prosemirror/node_test.go +++ b/pkg/prosemirror/node_test.go @@ -421,6 +421,82 @@ func TestLinkAttrs(t *testing.T) { assert.Equal(t, "Example", *attrs.Title) } +func TestTextLength(t *testing.T) { + t.Parallel() + + t.Run( + "empty doc", + func(t *testing.T) { + t.Parallel() + n := Node{Type: NodeDoc} + assert.Equal(t, 0, n.TextLength()) + }, + ) + + t.Run( + "single text node", + func(t *testing.T) { + t.Parallel() + text := "hello" + n := Node{Type: NodeText, Text: &text} + assert.Equal(t, 5, n.TextLength()) + }, + ) + + t.Run( + "paragraph with text", + func(t *testing.T) { + t.Parallel() + raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello world"}]}]}` + doc, err := Parse(raw) + require.NoError(t, err) + assert.Equal(t, 11, doc.TextLength()) + }, + ) + + t.Run( + "multiple paragraphs", + func(t *testing.T) { + t.Parallel() + raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"aaa"}]},{"type":"paragraph","content":[{"type":"text","text":"bb"}]}]}` + doc, err := Parse(raw) + require.NoError(t, err) + assert.Equal(t, 5, doc.TextLength()) + }, + ) + + t.Run( + "formatted text counts only text", + func(t *testing.T) { + t.Parallel() + raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"plain "},{"type":"text","marks":[{"type":"bold"}],"text":"bold"}]}]}` + doc, err := Parse(raw) + require.NoError(t, err) + assert.Equal(t, 10, doc.TextLength()) + }, + ) + + t.Run( + "nested list structure", + func(t *testing.T) { + t.Parallel() + raw := `{"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item 1"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item 2"}]}]}]}]}` + doc, err := Parse(raw) + require.NoError(t, err) + assert.Equal(t, 12, doc.TextLength()) + }, + ) + + t.Run( + "testdata document", + func(t *testing.T) { + t.Parallel() + doc := loadTestDocument(t) + assert.Greater(t, doc.TextLength(), 0) + }, + ) +} + func TestNodeWithNoAttrs(t *testing.T) { t.Parallel() diff --git a/pkg/validator/validator_prosemirror.go b/pkg/validator/validator_prosemirror.go index 776909d39..42c7d1dc6 100644 --- a/pkg/validator/validator_prosemirror.go +++ b/pkg/validator/validator_prosemirror.go @@ -15,6 +15,7 @@ package validator import ( + "fmt" "strings" "go.probo.inc/probo/pkg/prosemirror" @@ -42,3 +43,35 @@ func ProseMirrorDocumentContent() ValidatorFunc { return nil } } + +// ProseMirrorDocumentMaxTextLength validates that the total text content +// within a ProseMirror/Tiptap JSON document does not exceed maxLength bytes. +// Only user-visible text is counted; structural markup is excluded. +// Nil, empty, and whitespace-only values pass. Invalid JSON is skipped +// (let ProseMirrorDocumentContent handle format errors). +func ProseMirrorDocumentMaxTextLength(maxLength int) ValidatorFunc { + return func(value any) *ValidationError { + actualValue, isNil := dereferenceValue(value) + if isNil { + return nil + } + s, ok := actualValue.(string) + if !ok { + return newValidationError(ErrorCodeInvalidFormat, "value must be a string") + } + if strings.TrimSpace(s) == "" { + return nil + } + n, err := prosemirror.Parse(s) + if err != nil { + return nil + } + if n.TextLength() > maxLength { + return newValidationError( + ErrorCodeTooLong, + fmt.Sprintf("text content must be at most %d characters", maxLength), + ) + } + return nil + } +} diff --git a/pkg/validator/validator_prosemirror_test.go b/pkg/validator/validator_prosemirror_test.go index f072f3ec0..25a1cc742 100644 --- a/pkg/validator/validator_prosemirror_test.go +++ b/pkg/validator/validator_prosemirror_test.go @@ -14,7 +14,10 @@ package validator -import "testing" +import ( + "strings" + "testing" +) func TestProseMirrorDocumentContent(t *testing.T) { t.Parallel() @@ -51,3 +54,45 @@ func TestProseMirrorDocumentContent(t *testing.T) { }) } } + +func proseMirrorDoc(text string) string { + return `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"` + text + `"}]}]}` +} + +func TestProseMirrorDocumentMaxTextLength(t *testing.T) { + t.Parallel() + + const maxLen = 10 + + tests := []struct { + name string + value any + wantError bool + wantCode ErrorCode + }{ + {"nil value", nil, false, ""}, + {"nil *string", (*string)(nil), false, ""}, + {"empty string", "", false, ""}, + {"whitespace only", " \n\t ", false, ""}, + {"under limit", proseMirrorDoc("hello"), false, ""}, + {"at limit", proseMirrorDoc(strings.Repeat("a", maxLen)), false, ""}, + {"over limit", proseMirrorDoc(strings.Repeat("a", maxLen+1)), true, ErrorCodeTooLong}, + {"invalid json skipped", "not json", false, ""}, + {"non-string", 42, true, ErrorCodeInvalidFormat}, + } + + fn := ProseMirrorDocumentMaxTextLength(maxLen) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := fn(tt.value) + if (err != nil) != tt.wantError { + t.Errorf("ProseMirrorDocumentMaxTextLength() error = %v, wantError %v", err, tt.wantError) + } + if err != nil && tt.wantCode != "" && err.Code != tt.wantCode { + t.Errorf("expected code %s, got %s", tt.wantCode, err.Code) + } + }) + } +}