423 lines
11 KiB
Go
423 lines
11 KiB
Go
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
|
//
|
|
// Permission to use, copy, modify, and/or distribute this software for any
|
|
// purpose with or without fee is hereby granted, provided that the above
|
|
// copyright notice and this permission notice appear in all copies.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
// PERFORMANCE OF THIS SOFTWARE.
|
|
|
|
package docgen
|
|
|
|
import (
|
|
"html/template"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/getprobo/probo/pkg/coredata"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
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: "# Main Title\n\nThis is **bold** text with *italic* formatting.",
|
|
Version: 1,
|
|
Classification: ClassificationPublic,
|
|
Approver: "John Doe",
|
|
Description: "Test document description",
|
|
PublishedAt: &now,
|
|
Signatures: []SignatureData{
|
|
{
|
|
SignedBy: "Alice Smith",
|
|
SignedAt: &now,
|
|
State: coredata.DocumentVersionSignatureStateSigned,
|
|
RequestedAt: now,
|
|
RequestedBy: "Bob Johnson",
|
|
},
|
|
},
|
|
},
|
|
wantContains: []string{
|
|
"Test Document",
|
|
"<h1>Main Title</h1>",
|
|
"<strong>bold</strong>",
|
|
"<em>italic</em>",
|
|
"<td>1</td>",
|
|
"PUBLIC",
|
|
"John Doe",
|
|
"Test document description",
|
|
"Alice Smith",
|
|
"Bob Johnson",
|
|
},
|
|
},
|
|
{
|
|
name: "document with HTML characters that need escaping",
|
|
data: DocumentData{
|
|
Title: "Test & <Script> Title",
|
|
Content: "Normal markdown content",
|
|
Approver: "John <script>alert('xss')</script> Doe",
|
|
Description: "Description with & symbols and <tags>",
|
|
Signatures: []SignatureData{
|
|
{
|
|
SignedBy: "Alice & <Bob>",
|
|
RequestedBy: "Carol <script>",
|
|
State: coredata.DocumentVersionSignatureStateRequested,
|
|
},
|
|
},
|
|
},
|
|
wantContains: []string{
|
|
"Test &amp; &lt;Script&gt; Title",
|
|
"John &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt; Doe",
|
|
"Description with &amp; symbols and &lt;tags&gt;",
|
|
"Alice &amp; &lt;Bob&gt;",
|
|
"Carol &lt;script&gt;",
|
|
},
|
|
wantNotContains: []string{
|
|
"<script>alert('xss')</script>",
|
|
"Test & <Script> Title",
|
|
},
|
|
},
|
|
{
|
|
name: "document with markdown content",
|
|
data: DocumentData{
|
|
Title: "Markdown Test",
|
|
Content: "## Section 1\n\n- Item 1\n- Item 2\n\n**Bold text** and *italic text*\n\n```code block```",
|
|
},
|
|
wantContains: []string{
|
|
"<h2>Section 1</h2>",
|
|
"<ul>",
|
|
"<li>Item 1</li>",
|
|
"<li>Item 2</li>",
|
|
"</ul>",
|
|
"<strong>Bold text</strong>",
|
|
"<em>italic text</em>",
|
|
"<code>code block</code>",
|
|
},
|
|
},
|
|
{
|
|
name: "document with all classification types",
|
|
data: DocumentData{
|
|
Title: "Classification Test",
|
|
Classification: ClassificationConfidential,
|
|
},
|
|
wantContains: []string{"CONFIDENTIAL"},
|
|
},
|
|
{
|
|
name: "empty document",
|
|
data: DocumentData{},
|
|
wantContains: []string{
|
|
"<!DOCTYPE html>",
|
|
"<html",
|
|
"</html>",
|
|
},
|
|
},
|
|
{
|
|
name: "document with multiple signatures in different states",
|
|
data: DocumentData{
|
|
Title: "Signatures Test",
|
|
Signatures: []SignatureData{
|
|
{
|
|
SignedBy: "Signer 1",
|
|
SignedAt: &now,
|
|
State: coredata.DocumentVersionSignatureStateSigned,
|
|
RequestedAt: now,
|
|
RequestedBy: "Requester 1",
|
|
},
|
|
{
|
|
SignedBy: "Signer 2",
|
|
State: coredata.DocumentVersionSignatureStateRequested,
|
|
RequestedAt: now,
|
|
RequestedBy: "Requester 2",
|
|
},
|
|
},
|
|
},
|
|
wantContains: []string{
|
|
"Signer 1",
|
|
"Signer 2",
|
|
"Requester 1",
|
|
"Requester 2",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := RenderHTML(tt.data)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, result)
|
|
|
|
resultStr := string(result)
|
|
|
|
// Check that all expected content is present
|
|
for _, want := range tt.wantContains {
|
|
assert.Contains(t, resultStr, want, "Expected content not found: %s", want)
|
|
}
|
|
|
|
// Check that unwanted content is not present
|
|
for _, wantNot := range tt.wantNotContains {
|
|
assert.NotContains(t, resultStr, wantNot, "Unwanted content found: %s", wantNot)
|
|
}
|
|
|
|
// Basic HTML structure validation
|
|
assert.Contains(t, resultStr, "<!DOCTYPE html>")
|
|
assert.Contains(t, resultStr, "<html")
|
|
assert.Contains(t, resultStr, "</html>")
|
|
assert.Contains(t, resultStr, "<head>")
|
|
assert.Contains(t, resultStr, "</head>")
|
|
assert.Contains(t, resultStr, "<body>")
|
|
assert.Contains(t, resultStr, "</body>")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRenderHTML_ErrorHandling(t *testing.T) {
|
|
// Test with data that should not cause errors
|
|
data := DocumentData{
|
|
Title: "Valid Document",
|
|
Content: "Valid content",
|
|
}
|
|
|
|
result, err := RenderHTML(data)
|
|
assert.NoError(t, err)
|
|
assert.NotEmpty(t, result)
|
|
}
|
|
|
|
func TestTemplateFunctions(t *testing.T) {
|
|
t.Run("now function", func(t *testing.T) {
|
|
nowFunc := templateFuncs["now"].(func() time.Time)
|
|
result := nowFunc()
|
|
assert.True(t, time.Since(result) < time.Second)
|
|
})
|
|
|
|
t.Run("eq function", func(t *testing.T) {
|
|
eqFunc := templateFuncs["eq"].(func(string, string) bool)
|
|
assert.True(t, eqFunc("test", "test"))
|
|
assert.False(t, eqFunc("test", "other"))
|
|
})
|
|
|
|
t.Run("lower function", func(t *testing.T) {
|
|
lowerFunc := templateFuncs["lower"].(func(string) string)
|
|
assert.Equal(t, "hello world", lowerFunc("HELLO WORLD"))
|
|
assert.Equal(t, "test", lowerFunc("Test"))
|
|
})
|
|
|
|
t.Run("classificationString function", func(t *testing.T) {
|
|
classFunc := templateFuncs["classificationString"].(func(Classification) string)
|
|
assert.Equal(t, "PUBLIC", classFunc(ClassificationPublic))
|
|
assert.Equal(t, "CONFIDENTIAL", classFunc(ClassificationConfidential))
|
|
})
|
|
|
|
t.Run("formatContent function", func(t *testing.T) {
|
|
formatFunc := templateFuncs["formatContent"].(func(string) template.HTML)
|
|
|
|
// Test markdown conversion
|
|
result := formatFunc("**bold** text")
|
|
assert.Contains(t, string(result), "<strong>bold</strong>")
|
|
|
|
// Test basic text
|
|
result = formatFunc("simple text")
|
|
assert.Contains(t, string(result), "<p>simple text</p>")
|
|
|
|
// Test empty content - goldmark produces empty output for empty input
|
|
result = formatFunc("")
|
|
// Empty content should produce empty result from goldmark
|
|
assert.Equal(t, template.HTML(""), result)
|
|
})
|
|
}
|
|
|
|
func TestClassificationConstants(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
classification Classification
|
|
expected string
|
|
}{
|
|
{"public", ClassificationPublic, "PUBLIC"},
|
|
{"internal", ClassificationInternal, "INTERNAL"},
|
|
{"confidential", ClassificationConfidential, "CONFIDENTIAL"},
|
|
{"secret", ClassificationSecret, "SECRET"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assert.Equal(t, tt.expected, string(tt.classification))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHTMLEscaping(t *testing.T) {
|
|
dangerousData := DocumentData{
|
|
Title: "<script>alert('xss')</script>",
|
|
Approver: "User & <Company>",
|
|
Description: "Text with 'quotes' & \"double quotes\"",
|
|
Signatures: []SignatureData{
|
|
{
|
|
SignedBy: "<malicious>tag",
|
|
RequestedBy: "User & Company",
|
|
State: coredata.DocumentVersionSignatureStateRequested,
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := RenderHTML(dangerousData)
|
|
require.NoError(t, err)
|
|
|
|
resultStr := string(result)
|
|
|
|
// Verify dangerous content is escaped
|
|
assert.NotContains(t, resultStr, "<script>alert('xss')</script>")
|
|
assert.NotContains(t, resultStr, "<malicious>tag")
|
|
assert.Contains(t, resultStr, "&lt;script&gt;")
|
|
assert.Contains(t, resultStr, "&amp;")
|
|
assert.Contains(t, resultStr, "&#39;")
|
|
}
|
|
|
|
func TestMarkdownRendering(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
markdown string
|
|
want []string
|
|
}{
|
|
{
|
|
name: "headers",
|
|
markdown: "# H1\n## H2\n### H3",
|
|
want: []string{"<h1>H1</h1>", "<h2>H2</h2>", "<h3>H3</h3>"},
|
|
},
|
|
{
|
|
name: "emphasis",
|
|
markdown: "**bold** and *italic*",
|
|
want: []string{"<strong>bold</strong>", "<em>italic</em>"},
|
|
},
|
|
{
|
|
name: "lists",
|
|
markdown: "- Item 1\n- Item 2",
|
|
want: []string{"<ul>", "<li>Item 1</li>", "<li>Item 2</li>", "</ul>"},
|
|
},
|
|
{
|
|
name: "paragraphs",
|
|
markdown: "Paragraph 1\n\nParagraph 2",
|
|
want: []string{"<p>Paragraph 1</p>", "<p>Paragraph 2</p>"},
|
|
},
|
|
{
|
|
name: "code",
|
|
markdown: "`inline code` and\n```\ncode block\n```",
|
|
want: []string{"<code>inline code</code>", "<pre><code>code block"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
data := DocumentData{
|
|
Title: "Markdown Test",
|
|
Content: tt.markdown,
|
|
}
|
|
|
|
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,
|
|
RequestedBy: "Requester",
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := RenderHTML(data)
|
|
assert.NoError(t, err)
|
|
assert.NotEmpty(t, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLargeContent(t *testing.T) {
|
|
// Create a large markdown content
|
|
var largeContent strings.Builder
|
|
for i := 0; i < 1000; i++ {
|
|
largeContent.WriteString("# Section ")
|
|
largeContent.WriteString(string(rune('A' + i%26)))
|
|
largeContent.WriteString("\n\nThis is a paragraph with **bold** and *italic* text.\n\n")
|
|
largeContent.WriteString("- List item 1\n- List item 2\n- List item 3\n\n")
|
|
}
|
|
|
|
data := DocumentData{
|
|
Title: "Large Document",
|
|
Content: 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: "# Title\n\nThis is **bold** text with *italic* formatting.\n\n- Item 1\n- Item 2",
|
|
Version: 1,
|
|
Classification: ClassificationPublic,
|
|
Approver: "John Doe",
|
|
Description: "Benchmark test document",
|
|
PublishedAt: &now,
|
|
Signatures: []SignatureData{
|
|
{
|
|
SignedBy: "Alice Smith",
|
|
SignedAt: &now,
|
|
State: coredata.DocumentVersionSignatureStateSigned,
|
|
RequestedAt: now,
|
|
RequestedBy: "Bob Johnson",
|
|
},
|
|
},
|
|
}
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
_, err := RenderHTML(data)
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
}
|
|
}
|