// Copyright (c) 2025-2026 Probo Inc . // // 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 docgen import ( "encoding/json" "html/template" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.probo.inc/probo/pkg/coredata" ) func TestRenderHTML(t *testing.T) { now := time.Now() tests := []struct { name string data DocumentData wantContains []string wantNotContains []string }{ { name: "basic document with all fields", data: DocumentData{ Title: "Test Document", Content: json.RawMessage( []byte(`{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"Main Title"}]},{"type":"paragraph","content":[{"type":"text","text":"This is "},{"type":"text","marks":[{"type":"bold"}],"text":"bold"},{"type":"text","text":" text with "},{"type":"text","marks":[{"type":"italic"}],"text":"italic"},{"type":"text","text":" formatting."}]}]}`), ), Major: 1, Classification: ClassificationPublic, Approvers: []string{"John Doe"}, PublishedAt: &now, Signatures: []SignatureData{ { SignedBy: "Alice Smith", SignedAt: &now, State: coredata.DocumentVersionSignatureStateSigned, RequestedAt: now, }, }, }, wantContains: []string{ "Test Document", "

Main Title

", "bold", "italic", "1.0", "PUBLIC", "John Doe", "Alice Smith", }, }, { name: "document with HTML characters that need escaping", data: DocumentData{ Title: "Test & Doe"}, Signatures: []SignatureData{ { SignedBy: "Alice & ", State: coredata.DocumentVersionSignatureStateRequested, }, }, }, wantContains: []string{ "Test & <Script> Title", "John <script>alert('xss')</script> Doe", "Alice & <Bob>", }, wantNotContains: []string{ "", "Test & ", Approvers: []string{"User & "}, Signatures: []SignatureData{ { SignedBy: "tag", State: coredata.DocumentVersionSignatureStateRequested, }, }, } result, err := RenderHTML(dangerousData) require.NoError(t, err) resultStr := string(result) // Verify dangerous content is escaped assert.NotContains(t, resultStr, "") assert.NotContains(t, resultStr, "tag") assert.Contains(t, resultStr, "<script>") assert.Contains(t, resultStr, "&") assert.Contains(t, resultStr, "'") } func TestProseMirrorContentRendering(t *testing.T) { tests := []struct { name string content string want []string }{ { name: "headers", content: `{"type":"doc","content":[` + `{"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"H1"}]},` + `{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"H2"}]},` + `{"type":"heading","attrs":{"level":3},"content":[{"type":"text","text":"H3"}]}` + `]}`, want: []string{"

H1

", "

H2

", "

H3

"}, }, { name: "emphasis", content: `{"type":"doc","content":[{"type":"paragraph","content":[` + `{"type":"text","marks":[{"type":"bold"}],"text":"bold"},` + `{"type":"text","text":" and "},` + `{"type":"text","marks":[{"type":"italic"}],"text":"italic"}` + `]}]}`, want: []string{"bold", "italic"}, }, { name: "lists", content: `{"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"}]}]}` + `]}]}`, want: []string{"
    ", "
  • Item 1

  • ", "
  • Item 2

  • ", "
"}, }, { name: "paragraphs", content: `{"type":"doc","content":[` + `{"type":"paragraph","content":[{"type":"text","text":"Paragraph 1"}]},` + `{"type":"paragraph","content":[{"type":"text","text":"Paragraph 2"}]}` + `]}`, want: []string{"

Paragraph 1

", "

Paragraph 2

"}, }, { name: "code", content: `{"type":"doc","content":[` + `{"type":"paragraph","content":[{"type":"text","marks":[{"type":"code"}],"text":"inline code"}]},` + `{"type":"paragraph","content":[{"type":"text","text":" and "}]},` + `{"type":"codeBlock","content":[{"type":"text","text":"code block"}]}` + `]}`, want: []string{"inline code", "
code block
"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { data := DocumentData{ Title: "ProseMirror Test", Content: json.RawMessage([]byte(tt.content)), } result, err := RenderHTML(data) require.NoError(t, err) resultStr := string(result) for _, want := range tt.want { assert.Contains(t, resultStr, want) } }) } } func TestDocumentVersionSignatureStates(t *testing.T) { now := time.Now() states := []coredata.DocumentVersionSignatureState{ coredata.DocumentVersionSignatureStateRequested, coredata.DocumentVersionSignatureStateSigned, // Add other states if they exist } for _, state := range states { t.Run(string(state), func(t *testing.T) { data := DocumentData{ Title: "State Test", Signatures: []SignatureData{ { SignedBy: "Test User", State: state, RequestedAt: now, }, }, } result, err := RenderHTML(data) assert.NoError(t, err) assert.NotEmpty(t, result) }) } } func TestLargeContent(t *testing.T) { var largeContent strings.Builder largeContent.WriteString(`{"type":"doc","content":[`) for i := range 1000 { if i > 0 { largeContent.WriteByte(',') } largeContent.WriteString(`{"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"Section `) largeContent.WriteByte(byte('A' + i%26)) largeContent.WriteString(`"}]},`) largeContent.WriteString(`{"type":"paragraph","content":[` + `{"type":"text","text":"This is a paragraph with "},` + `{"type":"text","marks":[{"type":"bold"}],"text":"bold"},` + `{"type":"text","text":" and "},` + `{"type":"text","marks":[{"type":"italic"}],"text":"italic"},` + `{"type":"text","text":" text."}` + `]},`) largeContent.WriteString(`{"type":"bulletList","content":[` + `{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"List item 1"}]}]},` + `{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"List item 2"}]}]},` + `{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"List item 3"}]}]}` + `]}`) } largeContent.WriteString(`]}`) data := DocumentData{ Title: "Large Document", Content: json.RawMessage([]byte(largeContent.String())), } result, err := RenderHTML(data) assert.NoError(t, err) assert.NotEmpty(t, result) assert.True(t, len(result) > 10000) // Should be reasonably large } func BenchmarkGenerateHTML(b *testing.B) { now := time.Now() data := DocumentData{ Title: "Benchmark Document", Content: json.RawMessage([]byte( `{"type":"doc","content":[` + `{"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"Title"}]},` + `{"type":"paragraph","content":[` + `{"type":"text","text":"This is "},` + `{"type":"text","marks":[{"type":"bold"}],"text":"bold"},` + `{"type":"text","text":" text with "},` + `{"type":"text","marks":[{"type":"italic"}],"text":"italic"},` + `{"type":"text","text":" formatting."}` + `]},` + `{"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"}]}]}` + `]}` + `]}`, )), Major: 1, Classification: ClassificationPublic, Approvers: []string{"John Doe"}, PublishedAt: &now, Signatures: []SignatureData{ { SignedBy: "Alice Smith", SignedAt: &now, State: coredata.DocumentVersionSignatureStateSigned, RequestedAt: now, }, }, } b.ResetTimer() for i := 0; i < b.N; i++ { _, err := RenderHTML(data) if err != nil { b.Fatal(err) } } }