Skip to content

Commit 8fef412

Browse files
committed
Add PDF encryption support with userPassword and ownerPassword
Introduce PdfPassword DDD value object to validate non-empty password strings. Add userPassword and ownerPassword fields to PdfOutputOptions (cross-cutting, available on all request types). Expose SetUserPassword, SetOwnerPassword, and SetEncryption convenience methods on the builder.
1 parent 09b6c02 commit 8fef412

5 files changed

Lines changed: 323 additions & 0 deletions

File tree

src/Gotenberg.Sharp.Api.Client/Domain/Builders/Faceted/PdfOutputOptionsBuilder.cs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
// See the License for the specific language governing permissions and
1414
// limitations under the License.
1515

16+
using Gotenberg.Sharp.API.Client.Domain.ValueObjects;
17+
1618
using Newtonsoft.Json.Linq;
1719

1820
namespace Gotenberg.Sharp.API.Client.Domain.Builders.Faceted;
@@ -112,4 +114,62 @@ public PdfOutputOptionsBuilder SetMetadata(JObject metadata)
112114

113115
return this;
114116
}
117+
118+
/// <summary>
119+
/// Sets the password required to open the resulting PDF.
120+
/// </summary>
121+
/// <param name="password">A validated PDF password.</param>
122+
/// <returns>The builder instance for method chaining.</returns>
123+
public PdfOutputOptionsBuilder SetUserPassword(PdfPassword password)
124+
{
125+
_options.UserPassword = password ?? throw new ArgumentNullException(nameof(password));
126+
127+
return this;
128+
}
129+
130+
/// <summary>
131+
/// Sets the password required to open the resulting PDF.
132+
/// </summary>
133+
/// <param name="password">A non-empty password string.</param>
134+
/// <returns>The builder instance for method chaining.</returns>
135+
public PdfOutputOptionsBuilder SetUserPassword(string password)
136+
{
137+
return SetUserPassword(PdfPassword.Create(password));
138+
}
139+
140+
/// <summary>
141+
/// Sets the password required to change permissions or edit the resulting PDF.
142+
/// </summary>
143+
/// <param name="password">A validated PDF password.</param>
144+
/// <returns>The builder instance for method chaining.</returns>
145+
public PdfOutputOptionsBuilder SetOwnerPassword(PdfPassword password)
146+
{
147+
_options.OwnerPassword = password ?? throw new ArgumentNullException(nameof(password));
148+
149+
return this;
150+
}
151+
152+
/// <summary>
153+
/// Sets the password required to change permissions or edit the resulting PDF.
154+
/// </summary>
155+
/// <param name="password">A non-empty password string.</param>
156+
/// <returns>The builder instance for method chaining.</returns>
157+
public PdfOutputOptionsBuilder SetOwnerPassword(string password)
158+
{
159+
return SetOwnerPassword(PdfPassword.Create(password));
160+
}
161+
162+
/// <summary>
163+
/// Sets both user and owner passwords for the resulting PDF.
164+
/// </summary>
165+
/// <param name="userPassword">Password required to open the PDF.</param>
166+
/// <param name="ownerPassword">Password required to change permissions.</param>
167+
/// <returns>The builder instance for method chaining.</returns>
168+
public PdfOutputOptionsBuilder SetEncryption(string userPassword, string ownerPassword)
169+
{
170+
SetUserPassword(userPassword);
171+
SetOwnerPassword(ownerPassword);
172+
173+
return this;
174+
}
115175
}

src/Gotenberg.Sharp.Api.Client/Domain/Requests/Facets/PdfOutputOptions.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
// See the License for the specific language governing permissions and
1414
// limitations under the License.
1515

16+
using Gotenberg.Sharp.API.Client.Domain.ValueObjects;
17+
1618
using Newtonsoft.Json.Linq;
1719

1820
namespace Gotenberg.Sharp.API.Client.Domain.Requests.Facets;
@@ -54,4 +56,16 @@ public class PdfOutputOptions : FacetBase
5456
/// </summary>
5557
[MultiFormHeader(Constants.Gotenberg.PdfOutput.MetaData)]
5658
public JObject? MetaData { get; set; }
59+
60+
/// <summary>
61+
/// The password required to open the PDF.
62+
/// </summary>
63+
[MultiFormHeader(Constants.Gotenberg.PdfOutput.UserPassword)]
64+
public PdfPassword? UserPassword { get; set; }
65+
66+
/// <summary>
67+
/// The password required to change permissions or edit the PDF.
68+
/// </summary>
69+
[MultiFormHeader(Constants.Gotenberg.PdfOutput.OwnerPassword)]
70+
public PdfPassword? OwnerPassword { get; set; }
5771
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright 2019-2026 Chris Mohan, Jaben Cargman
2+
// and GotenbergSharpApiClient Contributors
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
namespace Gotenberg.Sharp.API.Client.Domain.ValueObjects;
17+
18+
/// <summary>
19+
/// Represents a validated PDF password used for encryption.
20+
/// Used for both userPassword (required to open) and ownerPassword (required to change permissions).
21+
/// </summary>
22+
public sealed class PdfPassword : IEquatable<PdfPassword>
23+
{
24+
public string Value { get; }
25+
26+
private PdfPassword(string value)
27+
{
28+
Value = value;
29+
}
30+
31+
/// <summary>
32+
/// Creates a validated PDF password.
33+
/// </summary>
34+
/// <param name="password">A non-empty password string.</param>
35+
/// <exception cref="ArgumentException">Thrown when the password is null or whitespace.</exception>
36+
public static PdfPassword Create(string password)
37+
{
38+
if (string.IsNullOrWhiteSpace(password))
39+
throw new ArgumentException("PDF password must not be null or empty.", nameof(password));
40+
41+
return new PdfPassword(password);
42+
}
43+
44+
public override string ToString() => Value;
45+
46+
public bool Equals(PdfPassword? other) => other is not null && Value == other.Value;
47+
48+
public override bool Equals(object? obj) => Equals(obj as PdfPassword);
49+
50+
public override int GetHashCode() => Value.GetHashCode();
51+
52+
public static implicit operator string(PdfPassword password) => password.Value;
53+
54+
public static bool operator ==(PdfPassword? left, PdfPassword? right) => Equals(left, right);
55+
56+
public static bool operator !=(PdfPassword? left, PdfPassword? right) => !Equals(left, right);
57+
}

src/Gotenberg.Sharp.Api.Client/Infrastructure/Constants.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ private static class CrossCutting
8888

8989
internal const string MetaData = "metadata";
9090

91+
internal const string UserPassword = "userPassword";
92+
93+
internal const string OwnerPassword = "ownerPassword";
94+
9195
internal static class FileNames
9296
{
9397
internal const string Index = "index.html";
@@ -108,6 +112,10 @@ public static class PdfOutput
108112
public const string GenerateTaggedPdf = CrossCutting.GenerateTaggedPdf;
109113

110114
public const string MetaData = CrossCutting.MetaData;
115+
116+
public const string UserPassword = CrossCutting.UserPassword;
117+
118+
public const string OwnerPassword = CrossCutting.OwnerPassword;
111119
}
112120

113121
/// <summary>
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
using Gotenberg.Sharp.API.Client.Domain.Builders;
2+
using Gotenberg.Sharp.API.Client.Domain.Builders.Faceted;
3+
using Gotenberg.Sharp.API.Client.Domain.Requests.Facets;
4+
using Gotenberg.Sharp.API.Client.Domain.Settings;
5+
using Gotenberg.Sharp.API.Client.Domain.ValueObjects;
6+
using Gotenberg.Sharp.API.Client.Extensions;
7+
using Microsoft.Extensions.DependencyInjection;
8+
9+
namespace GotenbergSharpClient.Tests;
10+
11+
[TestFixture]
12+
public class EncryptionOptionsTests
13+
{
14+
#region PdfPassword Value Object Tests
15+
16+
[Test]
17+
public void PdfPassword_Create_WithValidPassword_ReturnsInstance()
18+
{
19+
var password = PdfPassword.Create("secret123");
20+
21+
password.Value.Should().Be("secret123");
22+
password.ToString().Should().Be("secret123");
23+
}
24+
25+
[TestCase(null)]
26+
[TestCase("")]
27+
[TestCase(" ")]
28+
public void PdfPassword_Create_WithNullOrEmpty_ThrowsArgumentException(string? input)
29+
{
30+
var act = () => PdfPassword.Create(input!);
31+
32+
act.Should().ThrowExactly<ArgumentException>();
33+
}
34+
35+
[Test]
36+
public void PdfPassword_Equality_WithSameValue_ReturnsTrue()
37+
{
38+
var a = PdfPassword.Create("secret");
39+
var b = PdfPassword.Create("secret");
40+
41+
(a == b).Should().BeTrue();
42+
a.Equals(b).Should().BeTrue();
43+
}
44+
45+
[Test]
46+
public void PdfPassword_ImplicitConversion_ToStringReturnsValue()
47+
{
48+
PdfPassword password = PdfPassword.Create("secret");
49+
string result = password;
50+
51+
result.Should().Be("secret");
52+
}
53+
54+
#endregion
55+
56+
#region Builder Tests
57+
58+
[Test]
59+
public void SetUserPassword_WithString_SetsProperty()
60+
{
61+
var builder = new HtmlRequestBuilder();
62+
63+
builder.SetPdfOutputOptions(o => o.SetUserPassword("openme"));
64+
var request = builder.Build();
65+
66+
request.PdfOutputOptions!.UserPassword.Should().NotBeNull();
67+
request.PdfOutputOptions.UserPassword!.Value.Should().Be("openme");
68+
}
69+
70+
[Test]
71+
public void SetOwnerPassword_WithString_SetsProperty()
72+
{
73+
var builder = new HtmlRequestBuilder();
74+
75+
builder.SetPdfOutputOptions(o => o.SetOwnerPassword("editme"));
76+
var request = builder.Build();
77+
78+
request.PdfOutputOptions!.OwnerPassword.Should().NotBeNull();
79+
request.PdfOutputOptions.OwnerPassword!.Value.Should().Be("editme");
80+
}
81+
82+
[Test]
83+
public void SetEncryption_SetsBothPasswords()
84+
{
85+
var builder = new HtmlRequestBuilder();
86+
87+
builder.SetPdfOutputOptions(o => o.SetEncryption("user123", "owner456"));
88+
var request = builder.Build();
89+
90+
request.PdfOutputOptions!.UserPassword!.Value.Should().Be("user123");
91+
request.PdfOutputOptions.OwnerPassword!.Value.Should().Be("owner456");
92+
}
93+
94+
[Test]
95+
public void SetUserPassword_WithEmptyString_ThrowsArgumentException()
96+
{
97+
var builder = new HtmlRequestBuilder();
98+
99+
var act = () => builder.SetPdfOutputOptions(o => o.SetUserPassword(""));
100+
101+
act.Should().ThrowExactly<ArgumentException>();
102+
}
103+
104+
#endregion
105+
106+
#region HTTP Content Serialization Tests
107+
108+
[Test]
109+
public void UserPassword_SerializesToCorrectHttpContent()
110+
{
111+
var options = new PdfOutputOptions
112+
{
113+
UserPassword = PdfPassword.Create("openme")
114+
};
115+
116+
var httpContents = options.ToHttpContent().ToList();
117+
var content = httpContents.FirstOrDefault(c =>
118+
c.Headers.ContentDisposition?.Name == "userPassword");
119+
120+
content.Should().NotBeNull();
121+
content!.ReadAsStringAsync().Result.Should().Be("openme");
122+
}
123+
124+
[Test]
125+
public void OwnerPassword_SerializesToCorrectHttpContent()
126+
{
127+
var options = new PdfOutputOptions
128+
{
129+
OwnerPassword = PdfPassword.Create("editme")
130+
};
131+
132+
var httpContents = options.ToHttpContent().ToList();
133+
var content = httpContents.FirstOrDefault(c =>
134+
c.Headers.ContentDisposition?.Name == "ownerPassword");
135+
136+
content.Should().NotBeNull();
137+
content!.ReadAsStringAsync().Result.Should().Be("editme");
138+
}
139+
140+
[Test]
141+
public void NullPasswords_NotIncludedInHttpContent()
142+
{
143+
var options = new PdfOutputOptions();
144+
145+
var httpContents = options.ToHttpContent().ToList();
146+
147+
httpContents.FirstOrDefault(c =>
148+
c.Headers.ContentDisposition?.Name == "userPassword").Should().BeNull();
149+
httpContents.FirstOrDefault(c =>
150+
c.Headers.ContentDisposition?.Name == "ownerPassword").Should().BeNull();
151+
}
152+
153+
#endregion
154+
155+
#region Integration Tests
156+
157+
[Test]
158+
public async Task HtmlToPdf_WithEncryption_Succeeds()
159+
{
160+
var services = new ServiceCollection();
161+
services.AddOptions<GotenbergSharpClientOptions>()
162+
.Configure(options =>
163+
{
164+
options.ServiceUrl = new Uri("http://localhost:3000");
165+
options.BasicAuthUsername = "testuser";
166+
options.BasicAuthPassword = "testpass";
167+
});
168+
services.AddGotenbergSharpClient();
169+
var client = services.BuildServiceProvider()
170+
.GetRequiredService<Gotenberg.Sharp.API.Client.GotenbergSharpClient>();
171+
172+
var builder = new HtmlRequestBuilder()
173+
.AddDocument(doc => doc.SetBody(
174+
"<html><body><h1>Encrypted PDF</h1></body></html>"))
175+
.SetPdfOutputOptions(o => o.SetEncryption("user123", "owner456"));
176+
177+
var result = await client.HtmlToPdfAsync(builder);
178+
179+
result.Should().NotBeNull();
180+
result.Length.Should().BeGreaterThan(0);
181+
}
182+
183+
#endregion
184+
}

0 commit comments

Comments
 (0)