feat(server): entitlement primitive (#14964)

#### PR Dependency Tree


* **PR #14964** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added entitlement resolution to validate licenses and derive plan,
quotas, expiry and flags.
* Introduced persistent quota/entitlement state for users and workspaces
with legacy sync behavior.
* Real-time quota-state operations and change events for monitoring
usage.

* **Chores**
  * Updated workspace dependencies to add cryptography/hash crates.

* **Tests**
* Added native entitlement tests covering validation, quantity handling,
and signature/expiry cases.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14964)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-05-14 18:25:03 +08:00
committed by GitHub
parent 542da0b347
commit c8cdc488db
12 changed files with 1371 additions and 50 deletions
+726
View File
@@ -0,0 +1,726 @@
use std::collections::HashMap;
use aes_gcm::{
AesGcm, KeyInit,
aead::{
Aead,
generic_array::{GenericArray, typenum::U12},
},
aes::Aes256,
};
use chrono::{DateTime, Utc};
use napi::{Error as NapiError, Result, Status, bindgen_prelude::Buffer};
use napi_derive::napi;
use p256::{
ecdsa::{Signature, VerifyingKey, signature::Verifier},
pkcs8::DecodePublicKey,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
type Aes256Gcm12 = AesGcm<Aes256, U12, U12>;
type LicenseError = (&'static str, &'static str);
type LicenseResult<T> = std::result::Result<T, LicenseError>;
const ONE_MB: i64 = 1024 * 1024;
const ONE_GB: i64 = 1024 * ONE_MB;
const ONE_DAY_SECONDS: i64 = 24 * 60 * 60;
const MAX_SEAT_QUANTITY: i32 = 100_000;
#[napi(object)]
pub struct ResolveEntitlementInput {
pub deployment_type: String,
pub target_type: String,
pub target_id: Option<String>,
pub plan: Option<String>,
#[napi(ts_type = "number")]
pub quantity: Option<Value>,
pub signed_payload: Option<Buffer>,
pub public_key: Option<String>,
pub license_aes_key: Option<String>,
pub now: String,
}
#[derive(Debug)]
#[napi(object)]
pub struct ResolvedQuota {
pub blob_limit: i64,
pub storage_quota: i64,
pub seat_limit: Option<i32>,
pub seat_quota: Option<i64>,
pub history_period: i64,
pub copilot_action_limit: Option<i32>,
}
#[derive(Debug)]
#[napi(object)]
pub struct ResolvedEntitlement {
pub plan: String,
pub valid: bool,
pub status: String,
pub quantity: Option<i32>,
pub expires_at: Option<String>,
pub subject_id: Option<String>,
pub target_id: Option<String>,
pub recurring: Option<String>,
pub issued_at: Option<String>,
pub entity: Option<String>,
pub issuer: Option<String>,
pub quota: ResolvedQuota,
pub flags: HashMap<String, bool>,
pub error_code: Option<String>,
pub error_message: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct LicenseEnvelope {
payload: String,
signature: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LicensePayload {
entity: String,
issuer: String,
issued_at: String,
expires_at: String,
data: LicenseData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LicenseData {
id: String,
workspace_id: String,
plan: String,
recurring: String,
quantity: i32,
end_at: String,
}
struct PlanQuota {
name: &'static str,
blob_limit: i64,
storage_quota: i64,
history_period: i64,
member_limit: Option<i32>,
seat_quota: Option<i64>,
copilot_action_limit: Option<i32>,
unlimited_copilot: bool,
}
#[napi]
pub fn resolve_entitlement_v1(input: ResolveEntitlementInput) -> Result<ResolvedEntitlement> {
validate_input(&input)?;
let now = parse_time(&input.now)?;
if input.signed_payload.is_some() {
if input.deployment_type != "selfhosted" || input.target_type != "workspace" {
return invalid_arg("signedPayload is only supported for selfhosted workspace entitlements");
}
return resolve_selfhost_license(input, now);
}
let plan = input.plan.as_deref().unwrap_or_else(|| {
if input.deployment_type == "selfhosted" {
"selfhost_free"
} else {
"free"
}
});
if input.deployment_type == "selfhosted" && plan != "selfhost_free" {
return invalid_arg("selfhosted commercial entitlements require signedPayload");
}
let quantity = parse_quantity(input.quantity.as_ref())?;
Ok(active(plan, quantity, None))
}
fn validate_input(input: &ResolveEntitlementInput) -> Result<()> {
if !matches!(input.deployment_type.as_str(), "cloud" | "selfhosted") {
return invalid_arg("deploymentType must be cloud or selfhosted");
}
if !matches!(input.target_type.as_str(), "user" | "workspace" | "instance") {
return invalid_arg("targetType must be user, workspace, or instance");
}
parse_quantity(input.quantity.as_ref())?;
Ok(())
}
fn parse_quantity(quantity: Option<&Value>) -> Result<Option<i32>> {
let Some(quantity) = quantity else {
return Ok(None);
};
let Some(quantity) = quantity.as_i64() else {
return invalid_arg("quantity must be an integer");
};
if quantity <= 0 || quantity > MAX_SEAT_QUANTITY as i64 {
return invalid_arg("quantity must be between 1 and 100000");
}
Ok(Some(quantity as i32))
}
fn resolve_selfhost_license(input: ResolveEntitlementInput, now: DateTime<Utc>) -> Result<ResolvedEntitlement> {
let Some(payload) = input.signed_payload else {
return Ok(active("selfhost_free", None, None));
};
let Some(public_key) = input.public_key else {
return invalid_arg("publicKey is required for signed payload verification");
};
let Some(license_aes_key) = input.license_aes_key else {
return invalid_arg("licenseAesKey is required for signed payload verification");
};
let payload = match decrypt_license(payload.as_ref(), &license_aes_key)
.and_then(|decrypted| verify_license(&decrypted, &public_key))
{
Ok(payload) => payload,
Err((code, message)) => return Ok(invalid_license(code, message)),
};
if let Err((code, message)) = validate_license_payload(&payload) {
return Ok(invalid_license(code, message));
}
if payload.data.plan != "selfhostedteam" {
return Ok(invalid_license("invalid_payload", "license plan is not selfhostedteam"));
}
if let Some(target_id) = input.target_id.as_deref()
&& target_id != payload.data.workspace_id.as_str()
{
return Ok(invalid_license(
"workspace_mismatch",
"workspace mismatched with license",
));
}
if payload.issued_at.is_empty() || payload.entity.is_empty() || payload.issuer.is_empty() {
return Ok(invalid_license("invalid_payload", "license payload is incomplete"));
}
let file_expires_at = match parse_time(&payload.expires_at) {
Ok(time) => time,
Err(_) => return Ok(invalid_license("invalid_payload", "invalid expiresAt")),
};
let license_expires_at = match parse_time(&payload.data.end_at) {
Ok(time) => time,
Err(_) => return Ok(invalid_license("invalid_payload", "invalid endAt")),
};
let expires_at = file_expires_at.min(license_expires_at);
if expires_at < now {
let mut entitlement = expired(
"selfhost_team",
Some(payload.data.quantity),
Some(expires_at.to_rfc3339()),
);
entitlement.error_code = Some(
if license_expires_at < now && license_expires_at <= file_expires_at {
"expired_end_at"
} else {
"expired"
}
.to_string(),
);
fill_license_metadata(&mut entitlement, &payload);
return Ok(entitlement);
}
let mut entitlement = active(
"selfhost_team",
Some(payload.data.quantity),
Some(expires_at.to_rfc3339()),
);
fill_license_metadata(&mut entitlement, &payload);
Ok(entitlement)
}
fn fill_license_metadata(entitlement: &mut ResolvedEntitlement, payload: &LicensePayload) {
entitlement.subject_id = Some(payload.data.id.clone());
entitlement.target_id = Some(payload.data.workspace_id.clone());
entitlement.recurring = Some(payload.data.recurring.clone());
entitlement.issued_at = Some(payload.issued_at.clone());
entitlement.entity = Some(payload.entity.clone());
entitlement.issuer = Some(payload.issuer.clone());
}
fn validate_license_payload(payload: &LicensePayload) -> LicenseResult<()> {
if payload.data.id.is_empty()
|| payload.data.workspace_id.is_empty()
|| !matches!(payload.data.recurring.as_str(), "monthly" | "yearly" | "lifetime")
|| payload.data.quantity <= 0
|| payload.data.quantity > MAX_SEAT_QUANTITY
{
return Err(("invalid_payload", "license payload is incomplete"));
}
Ok(())
}
fn decrypt_license(buf: &[u8], aes_key: &str) -> LicenseResult<(Vec<u8>, Vec<u8>)> {
if buf.len() < 2 {
return Err(("invalid_file", "invalid license file"));
}
let iv_len = buf[0] as usize;
let tag_len = buf[1] as usize;
let payload_start = 2 + iv_len + tag_len;
if iv_len != 12 || tag_len != 12 || buf.len() <= payload_start {
return Err(("invalid_file", "invalid license file"));
}
let iv = &buf[2..2 + iv_len];
let tag = &buf[2 + iv_len..payload_start];
let payload = &buf[payload_start..];
let key = license_aes_key(aes_key)?;
let cipher = Aes256Gcm12::new_from_slice(&key).map_err(|_| ("invalid_key", "invalid aes key"))?;
let nonce = GenericArray::from_slice(iv);
let mut encrypted = Vec::with_capacity(payload.len() + tag.len());
encrypted.extend_from_slice(payload);
encrypted.extend_from_slice(tag);
let decrypted = cipher
.decrypt(nonce, encrypted.as_ref())
.map_err(|_| ("decrypt_failed", "failed to verify the license"))?;
Ok((iv.to_vec(), decrypted))
}
fn license_aes_key(aes_key: &str) -> LicenseResult<[u8; 32]> {
if aes_key.len() == 64
&& let Ok(decoded) = hex::decode(aes_key)
&& decoded.len() == 32
{
let mut key = [0; 32];
key.copy_from_slice(&decoded);
return Ok(key);
}
Ok(Sha256::digest(aes_key.as_bytes()).into())
}
fn verify_license(decrypted: &(Vec<u8>, Vec<u8>), public_key: &str) -> LicenseResult<LicensePayload> {
let (iv, decrypted) = decrypted;
let envelope: LicenseEnvelope =
serde_json::from_slice(decrypted).map_err(|_| ("invalid_file", "invalid license file"))?;
let signature = hex::decode(&envelope.signature).map_err(|_| ("invalid_signature", "invalid license signature"))?;
let signature = Signature::from_der(&signature).map_err(|_| ("invalid_signature", "invalid license signature"))?;
let verifying_key =
VerifyingKey::from_public_key_pem(public_key).map_err(|_| ("invalid_public_key", "invalid public key"))?;
let mut message = Vec::with_capacity(iv.len() + envelope.payload.len());
message.extend_from_slice(iv);
message.extend_from_slice(envelope.payload.as_bytes());
verifying_key
.verify(&message, &signature)
.map_err(|_| ("invalid_signature", "invalid license signature"))?;
serde_json::from_str::<LicensePayload>(&envelope.payload).map_err(|_| ("invalid_payload", "invalid license payload"))
}
fn active(plan: &str, quantity: Option<i32>, expires_at: Option<String>) -> ResolvedEntitlement {
let quantity = quantity_for_plan(plan, quantity);
let catalog = plan_catalog(plan, quantity);
ResolvedEntitlement {
plan: catalog.name.to_string(),
valid: true,
status: "active".to_string(),
quantity,
expires_at,
subject_id: None,
target_id: None,
recurring: None,
issued_at: None,
entity: None,
issuer: None,
quota: quota(&catalog),
flags: flags(&catalog),
error_code: None,
error_message: None,
}
}
fn expired(plan: &str, quantity: Option<i32>, expires_at: Option<String>) -> ResolvedEntitlement {
let quantity = quantity_for_plan(plan, quantity);
let catalog = plan_catalog(plan, quantity);
ResolvedEntitlement {
plan: catalog.name.to_string(),
valid: false,
status: "expired".to_string(),
quantity,
expires_at,
subject_id: None,
target_id: None,
recurring: None,
issued_at: None,
entity: None,
issuer: None,
quota: quota(&catalog),
flags: flags(&catalog),
error_code: Some("expired".to_string()),
error_message: Some("license expired".to_string()),
}
}
fn invalid_license(code: &'static str, message: &'static str) -> ResolvedEntitlement {
let catalog = plan_catalog("selfhost_free", None);
ResolvedEntitlement {
plan: catalog.name.to_string(),
valid: false,
status: "needs_reupload".to_string(),
quantity: None,
expires_at: None,
subject_id: None,
target_id: None,
recurring: None,
issued_at: None,
entity: None,
issuer: None,
quota: quota(&catalog),
flags: flags(&catalog),
error_code: Some(code.to_string()),
error_message: Some(message.to_string()),
}
}
fn quantity_for_plan(plan: &str, quantity: Option<i32>) -> Option<i32> {
if matches!(plan, "team" | "selfhost_team") {
quantity
} else {
None
}
}
fn plan_catalog(plan: &str, quantity: Option<i32>) -> PlanQuota {
let seats = quantity.unwrap_or(1);
match plan {
"pro" => PlanQuota {
name: "pro",
blob_limit: 100 * ONE_MB,
storage_quota: 100 * ONE_GB,
history_period: 30 * ONE_DAY_SECONDS,
member_limit: Some(10),
seat_quota: None,
copilot_action_limit: Some(10),
unlimited_copilot: false,
},
"lifetime_pro" => PlanQuota {
name: "lifetime_pro",
blob_limit: 100 * ONE_MB,
storage_quota: 1024 * ONE_GB,
history_period: 30 * ONE_DAY_SECONDS,
member_limit: Some(10),
seat_quota: None,
copilot_action_limit: Some(10),
unlimited_copilot: false,
},
"ai" => PlanQuota {
name: "ai",
blob_limit: 10 * ONE_MB,
storage_quota: 10 * ONE_GB,
history_period: 7 * ONE_DAY_SECONDS,
member_limit: Some(3),
seat_quota: None,
copilot_action_limit: None,
unlimited_copilot: true,
},
"team" | "selfhost_team" => {
let seat_quota = 20 * ONE_GB;
let storage_quota = (seats as i64)
.checked_mul(seat_quota)
.and_then(|storage| storage.checked_add(100 * ONE_GB))
.unwrap_or(i64::MAX);
PlanQuota {
name: if plan == "team" { "team" } else { "selfhost_team" },
blob_limit: 500 * ONE_MB,
storage_quota,
history_period: 30 * ONE_DAY_SECONDS,
member_limit: Some(seats),
seat_quota: Some(seat_quota),
copilot_action_limit: None,
unlimited_copilot: false,
}
}
"selfhost_free" => PlanQuota {
name: "selfhost_free",
blob_limit: 100 * ONE_MB,
storage_quota: 100 * ONE_GB,
history_period: 30 * ONE_DAY_SECONDS,
member_limit: Some(10),
seat_quota: None,
copilot_action_limit: Some(10),
unlimited_copilot: false,
},
_ => PlanQuota {
name: "free",
blob_limit: 10 * ONE_MB,
storage_quota: 10 * ONE_GB,
history_period: 7 * ONE_DAY_SECONDS,
member_limit: Some(3),
seat_quota: None,
copilot_action_limit: Some(10),
unlimited_copilot: false,
},
}
}
fn quota(catalog: &PlanQuota) -> ResolvedQuota {
ResolvedQuota {
blob_limit: catalog.blob_limit,
storage_quota: catalog.storage_quota,
seat_limit: catalog.member_limit,
seat_quota: catalog.seat_quota,
history_period: catalog.history_period,
copilot_action_limit: catalog.copilot_action_limit,
}
}
fn flags(catalog: &PlanQuota) -> HashMap<String, bool> {
let mut flags = HashMap::new();
flags.insert("unlimitedCopilot".to_string(), catalog.unlimited_copilot);
flags
}
fn parse_time(value: &str) -> Result<DateTime<Utc>> {
DateTime::parse_from_rfc3339(value)
.map(|value| value.with_timezone(&Utc))
.map_err(|err| NapiError::new(Status::InvalidArg, err.to_string()))
}
fn invalid_arg<T>(message: &'static str) -> Result<T> {
Err(NapiError::new(Status::InvalidArg, message))
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_WORKSPACE_ID: &str = "d6f52bc7-d62a-4822-804a-335fa7dfe5a6";
#[rustfmt::skip]
const TEST_PUBLIC_KEY: &str = "-----BEGIN PUBLIC KEY-----\n\
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqrxlczPknUuj4q4xx1VGr063Cgu7\n\
Hc3w7v4FGmoA5MNzzhrkho1ckDYw2wrX6zBnehFzcivURv80HherE2GQjg==\n\
-----END PUBLIC KEY-----";
const TEST_LICENSE_AES_KEY: &str = "TEST_LICENSE_AES_KEY";
fn input(plan: Option<&str>, quantity: Option<i32>) -> ResolveEntitlementInput {
ResolveEntitlementInput {
deployment_type: "cloud".to_string(),
target_type: "workspace".to_string(),
target_id: Some("workspace".to_string()),
plan: plan.map(str::to_string),
quantity: quantity.map(Value::from),
signed_payload: None,
public_key: None,
license_aes_key: None,
now: "2026-05-14T00:00:00Z".to_string(),
}
}
fn license_input(file: &str, workspace_id: &str) -> ResolveEntitlementInput {
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../server/src/__tests__/e2e/license/__fixtures__")
.join(file);
ResolveEntitlementInput {
deployment_type: "selfhosted".to_string(),
target_type: "workspace".to_string(),
target_id: Some(workspace_id.to_string()),
plan: None,
quantity: None,
signed_payload: Some(std::fs::read(fixture).unwrap().into()),
public_key: Some(TEST_PUBLIC_KEY.to_string()),
license_aes_key: Some(TEST_LICENSE_AES_KEY.to_string()),
now: "2026-05-14T00:00:00Z".to_string(),
}
}
fn decrypted_license(file: &str) -> (Vec<u8>, Vec<u8>) {
let input = license_input(file, TEST_WORKSPACE_ID);
let payload = input.signed_payload.unwrap();
decrypt_license(payload.as_ref(), TEST_LICENSE_AES_KEY).unwrap()
}
#[test]
fn decrypts_license_with_raw_or_hashed_aes_key() {
let input = license_input("valid.license", TEST_WORKSPACE_ID);
let payload = input.signed_payload.unwrap();
let hashed_key = hex::encode(Sha256::digest(TEST_LICENSE_AES_KEY.as_bytes()));
let raw = decrypt_license(payload.as_ref(), TEST_LICENSE_AES_KEY).unwrap();
let hashed = decrypt_license(payload.as_ref(), &hashed_key).unwrap();
assert_eq!(raw.0, hashed.0);
assert_eq!(raw.1, hashed.1);
}
#[test]
fn derives_plan_quota() {
let cases = [
("free", None, 3, 10 * ONE_GB, Some(10)),
("pro", None, 10, 100 * ONE_GB, Some(10)),
("lifetime_pro", None, 10, 1024 * ONE_GB, Some(10)),
("team", Some(5), 5, 200 * ONE_GB, None),
("selfhost_team", Some(20), 20, 500 * ONE_GB, None),
("selfhost_free", None, 10, 100 * ONE_GB, Some(10)),
];
for (plan, quantity, seat_limit, storage_quota, copilot_limit) in cases {
let mut input = input(Some(plan), quantity);
if plan == "selfhost_free" {
input.deployment_type = "selfhosted".to_string();
}
let resolved = resolve_entitlement_v1(input).unwrap();
assert!(resolved.valid, "{plan}");
assert_eq!(
resolved.quantity,
if matches!(plan, "team" | "selfhost_team") {
quantity
} else {
None
},
"{plan}"
);
assert_eq!(resolved.quota.seat_limit, Some(seat_limit), "{plan}");
assert_eq!(resolved.quota.storage_quota, storage_quota, "{plan}");
assert_eq!(resolved.quota.copilot_action_limit, copilot_limit, "{plan}");
}
}
#[test]
fn ignores_quantity_for_fixed_catalog_plans() {
for plan in ["free", "pro", "lifetime_pro", "ai", "selfhost_free"] {
let mut input = input(Some(plan), Some(50));
if plan == "selfhost_free" {
input.deployment_type = "selfhosted".to_string();
}
let resolved = resolve_entitlement_v1(input).unwrap();
assert_eq!(resolved.quantity, None, "{plan}");
assert_ne!(resolved.quota.seat_limit, Some(50), "{plan}");
}
}
#[test]
fn rejects_invalid_quantity() {
for quantity in [0, -1, MAX_SEAT_QUANTITY + 1] {
let err = resolve_entitlement_v1(input(Some("team"), Some(quantity))).unwrap_err();
assert_eq!(err.status, Status::InvalidArg, "{quantity}");
}
}
#[test]
fn rejects_unsigned_selfhosted_commercial_entitlements() {
for plan in ["pro", "lifetime_pro", "ai", "team", "selfhost_team"] {
let mut input = input(Some(plan), Some(50));
input.deployment_type = "selfhosted".to_string();
let err = resolve_entitlement_v1(input).unwrap_err();
assert_eq!(err.status, Status::InvalidArg, "{plan}");
}
}
#[test]
fn rejects_schema_errors() {
let mut input = input(Some("free"), None);
input.deployment_type = "local".to_string();
let err = resolve_entitlement_v1(input).unwrap_err();
assert_eq!(err.status, Status::InvalidArg);
}
#[test]
fn rejects_signed_payload_outside_selfhost_workspace_boundary() {
let cases = [
("cloud", "workspace"),
("selfhosted", "user"),
("selfhosted", "instance"),
];
for (deployment_type, target_type) in cases {
let mut input = license_input("valid.license", TEST_WORKSPACE_ID);
input.deployment_type = deployment_type.to_string();
input.target_type = target_type.to_string();
let err = resolve_entitlement_v1(input).unwrap_err();
assert_eq!(err.status, Status::InvalidArg, "{deployment_type}/{target_type}");
}
}
#[test]
fn verifies_selfhost_license_files() {
let cases = [
("valid.license", TEST_WORKSPACE_ID, true, "active", None, Some(20)),
(
"valid.license",
"other-workspace",
false,
"needs_reupload",
Some("workspace_mismatch"),
None,
),
(
"expired.license",
TEST_WORKSPACE_ID,
false,
"expired",
Some("expired"),
Some(20),
),
(
"expired-end-at.license",
TEST_WORKSPACE_ID,
false,
"expired",
Some("expired_end_at"),
Some(20),
),
];
for (file, workspace_id, valid, status, error_code, quantity) in cases {
let resolved = resolve_entitlement_v1(license_input(file, workspace_id)).unwrap();
assert_eq!(resolved.valid, valid, "{file}");
assert_eq!(resolved.status, status, "{file}");
assert_eq!(resolved.error_code.as_deref(), error_code, "{file}");
assert_eq!(resolved.quantity, quantity, "{file}");
if valid {
assert_eq!(resolved.plan, "selfhost_team", "{file}");
assert_eq!(resolved.quota.seat_limit, quantity, "{file}");
assert_eq!(resolved.quota.storage_quota, 500 * ONE_GB, "{file}");
assert_eq!(resolved.quota.blob_limit, 500 * ONE_MB, "{file}");
}
}
}
#[test]
fn verifies_signature_branch() {
let (iv, decrypted) = decrypted_license("valid.license");
let mut envelope: LicenseEnvelope = serde_json::from_slice(&decrypted).unwrap();
envelope.signature = "00".to_string();
let decrypted = serde_json::to_vec(&envelope).unwrap();
let err = verify_license(&(iv, decrypted), TEST_PUBLIC_KEY).unwrap_err();
assert_eq!(err.0, "invalid_signature");
}
#[test]
fn rejects_license_payload_schema_and_quantity_errors() {
let mut payload: LicensePayload = serde_json::from_str(
&serde_json::from_slice::<LicenseEnvelope>(&decrypted_license("valid.license").1)
.unwrap()
.payload,
)
.unwrap();
for quantity in [0, -1] {
payload.data.quantity = quantity;
let err = validate_license_payload(&payload).unwrap_err();
assert_eq!(err.0, "invalid_payload");
}
payload.data.quantity = 20;
payload.data.workspace_id.clear();
let err = validate_license_payload(&payload).unwrap_err();
assert_eq!(err.0, "invalid_payload");
}
}
+1
View File
@@ -4,6 +4,7 @@ mod utils;
pub mod doc;
pub mod doc_loader;
pub mod entitlement;
pub mod file_type;
pub mod hashcash;
pub mod html_sanitize;