1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
//! Signed-license contract — full lifecycle E2E in one process.
//!
//! Single integration-test binary so the order of scenarios is
//! deterministic and the `OnceLock<Tier>` semantics of
//! `pdfluent::set_license_payload` are exercised exactly as they would
//! be in a real customer process.
//!
//! Hard rules:
//! - No private keys on disk — keypair generated in-memory once,
//! used to sign all fixtures.
//! - No silent fallback — every error path is asserted to leave the
//! process tier unchanged.
//! - Typed `.code` (= `E-LICENSE-…`) asserted on every error scenario.
//!
//! Closes matrix flows F1 (valid signed payload), F2 (tampered),
//! F3 (expired), F8 (already-set, different tier), F9 (invalid /
//! malformed JSON), F13 (no silent fallback after failed activation).
use pdfluent::{
license_info, set_license_key, set_license_payload, set_license_public_key, Error, Tier,
};
use xfa_license::claims::LicensePayload;
use xfa_license::token::{generate_keypair, sign_license};
use xfa_license::Tier as XfaTier;
/// Future expiry (year 3000) — safely outside any realistic test
/// runtime.
const FUTURE_EXPIRY: u64 = 32_503_680_000;
/// Past expiry (year 2020).
const PAST_EXPIRY: u64 = 1_577_836_800;
fn payload(tier: XfaTier, expires_at: u64) -> LicensePayload {
LicensePayload {
licensee: "Test Licensee".into(),
email: "ci@pdfluent.test".into(),
company: "PDFluent CI".into(),
tier,
seats: 1,
issued_at: 1_700_000_000,
expires_at,
features: Some(vec!["xfa".into(), "pdfa-validation".into()]),
}
}
fn assert_code(err: &Error, expected: &str) {
assert_eq!(err.code(), expected, "wrong typed code on error: {err:?}",);
}
#[test]
fn signed_license_lifecycle_e2e() {
// ─── Setup: generate a fresh test keypair in-memory ────────────────
let (private_key, public_key) = generate_keypair();
// Inject the public key — first call. Idempotent re-call should
// succeed; conflicting re-call would error.
set_license_public_key(&public_key).expect("first set_license_public_key");
set_license_public_key(&public_key).expect("idempotent set_license_public_key");
// Default status BEFORE any activation must be Trial.
let info0 = license_info();
assert_eq!(info0.tier, Tier::Trial);
assert!(info0.expires_at.is_none());
assert!(info0.features.is_empty());
// ─── Scenario F2 — tampered signature ──────────────────────────────
{
let p = payload(XfaTier::Basic, FUTURE_EXPIRY);
let signed = sign_license(&private_key, &p).expect("sign valid");
// Flip one ASCII byte inside the base64 signature. Both the
// needle and replacement are ASCII so reconstructing the
// String from bytes is safe.
let needle = "\"signature\": \"";
let pos = signed.find(needle).unwrap() + needle.len();
let mut buf = signed.into_bytes();
buf[pos] = if buf[pos] == b'A' { b'B' } else { b'A' };
let tampered = String::from_utf8(buf).expect("ASCII-only mutation");
let err = set_license_payload(&tampered).expect_err("tampered must fail");
assert_code(&err, "E-LICENSE-INVALID-SIGNATURE");
// No silent fallback — tier stays Trial.
assert_eq!(license_info().tier, Tier::Trial);
}
// ─── Scenario F3 — expired payload ─────────────────────────────────
{
let p = payload(XfaTier::Basic, PAST_EXPIRY);
let signed = sign_license(&private_key, &p).expect("sign expired");
let err = set_license_payload(&signed).expect_err("expired must fail");
match &err {
Error::LicenseExpired { expires_at } => {
assert_eq!(*expires_at, PAST_EXPIRY);
}
other => panic!("expected LicenseExpired, got {other:?}"),
}
assert_code(&err, "E-LICENSE-EXPIRED");
// No silent fallback.
assert_eq!(license_info().tier, Tier::Trial);
}
// ─── Scenario F9 — malformed JSON ──────────────────────────────────
{
let err = set_license_payload("{not json").expect_err("malformed must fail");
assert_code(&err, "E-LICENSE-INVALID");
assert_eq!(license_info().tier, Tier::Trial);
}
// ─── Scenario F1 — valid signed payload ────────────────────────────
// Sign a Basic-tier payload. The umbrella maps Basic → Developer per
// SIGNED_LICENSE_PAYLOAD_ARCHITECTURE.md Q5.
let valid =
sign_license(&private_key, &payload(XfaTier::Basic, FUTURE_EXPIRY)).expect("sign valid");
set_license_payload(&valid).expect("valid payload activates");
let info = license_info();
assert_eq!(
info.tier,
Tier::Developer,
"xfa-license Basic → pdfluent Developer mapping"
);
assert!(
info.expires_at
.as_deref()
.map(|s| s.starts_with("3000-"))
.unwrap_or(false),
"expires_at must be ISO 8601 in year 3000, got {:?}",
info.expires_at
);
assert_eq!(info.licensee.as_deref(), Some("Test Licensee"));
assert_eq!(info.company.as_deref(), Some("PDFluent CI"));
assert_eq!(info.features, vec!["xfa", "pdfa-validation"]);
// output_is_marked must be FALSE on a paid tier.
assert!(
!info.output_is_marked,
"Developer tier must NOT mark output, got {info:?}"
);
// ─── Scenario F8 — idempotent re-activation (same tier) ────────────
set_license_payload(&valid).expect("idempotent re-activate same tier");
assert_eq!(license_info().tier, Tier::Developer);
// ─── Scenario F5 — already-set, DIFFERENT tier ─────────────────────
{
let enterprise = sign_license(&private_key, &payload(XfaTier::Enterprise, FUTURE_EXPIRY))
.expect("sign enterprise");
let err = set_license_payload(&enterprise).expect_err("conflicting tier must fail");
assert_code(&err, "E-LICENSE-INVALID");
// Tier stays at Developer.
assert_eq!(license_info().tier, Tier::Developer);
}
// ─── Scenario F4 — set_license_key auto-detects JSON payloads ──────
// Re-activating the same Developer-tier payload via set_license_key
// (the legacy entry point) must succeed because we auto-route JSON
// → set_license_payload internally.
set_license_key(&valid).expect("auto-detect JSON via set_license_key");
assert_eq!(license_info().tier, Tier::Developer);
}
#[test]
fn license_info_default_when_no_payload() {
// Distinct binary case is in signed_license_missing_key.rs.
// This test in the same binary as the lifecycle test runs AFTER
// the lifecycle (or before — irrelevant) and only verifies that
// license_info() never panics + always returns a structurally-
// valid LicenseInfo regardless of process state.
let info = license_info();
// Tier is always a valid variant.
let _ = matches!(
info.tier,
Tier::Trial | Tier::Developer | Tier::Team | Tier::Business | Tier::Enterprise
);
// features field is always present (Vec is never null).
let _ = info.features.len();
// output_is_marked must be true iff Trial.
assert_eq!(info.output_is_marked, info.tier == Tier::Trial);
}