Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
523 lines
17 KiB
Rust
523 lines
17 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use affine_core::access_control::{
|
|
AccessGrant, Deployment, EntitlementInput, EntitlementInputError, LicenseIssuance, LicenseIssuanceError,
|
|
LicenseIssuer, LicenseVerifier, Limits, Plan, Rights, TargetType, ValidatedEntitlement, describe_plan,
|
|
validate_entitlement_input,
|
|
};
|
|
use chrono::{DateTime, Utc};
|
|
use napi::{Error as NapiError, Result, Status, bindgen_prelude::Buffer};
|
|
use napi_derive::napi;
|
|
use serde_json::Value;
|
|
|
|
#[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 now: String,
|
|
}
|
|
|
|
#[napi(object)]
|
|
pub struct IssueLicenseInput {
|
|
pub license_id: String,
|
|
pub workspace_id: String,
|
|
pub seat_quantity: f64,
|
|
pub subscription_end: Option<String>,
|
|
pub private_key: String,
|
|
pub now: String,
|
|
}
|
|
|
|
#[napi]
|
|
pub fn issue_license_v1(input: IssueLicenseInput) -> Result<Buffer> {
|
|
let now = parse_time(&input.now)?;
|
|
let subscription_end = input.subscription_end.as_deref().map(parse_time).transpose()?;
|
|
let seat_quantity = parse_license_seat_quantity(input.seat_quantity)?;
|
|
LicenseIssuer::issue(
|
|
LicenseIssuance {
|
|
license_id: &input.license_id,
|
|
workspace_id: &input.workspace_id,
|
|
seat_quantity,
|
|
subscription_end,
|
|
now,
|
|
},
|
|
&input.private_key,
|
|
)
|
|
.map(Buffer::from)
|
|
.map_err(|error| {
|
|
let message = match error {
|
|
LicenseIssuanceError::InvalidPrivateKey => "invalid license private key",
|
|
LicenseIssuanceError::InvalidClaims => "invalid license claims",
|
|
LicenseIssuanceError::InvalidTimeWindow => "invalid license time window",
|
|
LicenseIssuanceError::Encoding => "failed to encode license envelope",
|
|
};
|
|
NapiError::new(Status::InvalidArg, message)
|
|
})
|
|
}
|
|
|
|
#[napi]
|
|
pub fn validate_license_seat_quantity_v1(seat_quantity: f64) -> Result<()> {
|
|
LicenseIssuer::validate_seat_quantity(parse_license_seat_quantity(seat_quantity)?)
|
|
.map_err(|_| NapiError::new(Status::InvalidArg, "invalid license seat quantity"))
|
|
}
|
|
|
|
fn parse_license_seat_quantity(seat_quantity: f64) -> Result<i32> {
|
|
if !seat_quantity.is_finite()
|
|
|| seat_quantity.fract() != 0.0
|
|
|| seat_quantity < i32::MIN as f64
|
|
|| seat_quantity > i32::MAX as f64
|
|
{
|
|
return invalid_arg("invalid license seat quantity");
|
|
}
|
|
Ok(seat_quantity as i32)
|
|
}
|
|
|
|
#[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>,
|
|
}
|
|
|
|
#[napi]
|
|
pub fn resolve_entitlement_v1(input: ResolveEntitlementInput) -> Result<ResolvedEntitlement> {
|
|
let now = parse_time(&input.now)?;
|
|
let deployment = parse_deployment(&input.deployment_type)?;
|
|
let target_type = parse_target_type(&input.target_type)?;
|
|
let quantity = parse_quantity(input.quantity.as_ref())?;
|
|
let plan = input
|
|
.plan
|
|
.as_deref()
|
|
.map(|plan| Plan::parse(plan).ok_or_else(|| NapiError::new(Status::InvalidArg, "unknown entitlement plan")))
|
|
.transpose()?;
|
|
let validated = validate_entitlement_input(EntitlementInput {
|
|
deployment,
|
|
target_type,
|
|
plan,
|
|
quantity,
|
|
signed: input.signed_payload.is_some(),
|
|
})
|
|
.map_err(entitlement_input_error)?;
|
|
if validated == ValidatedEntitlement::SignedLicense {
|
|
return resolve_selfhost_license(input, now);
|
|
}
|
|
let ValidatedEntitlement::Catalog(access) = validated else {
|
|
unreachable!();
|
|
};
|
|
let grant: AccessGrant = access.into();
|
|
Ok(active_with_grant(
|
|
grant.plan,
|
|
grant.quantity,
|
|
grant.limits,
|
|
grant.rights,
|
|
None,
|
|
))
|
|
}
|
|
|
|
fn parse_deployment(value: &str) -> Result<Deployment> {
|
|
match value {
|
|
"cloud" => Ok(Deployment::Cloud),
|
|
"selfhosted" => Ok(Deployment::SelfHosted),
|
|
_ => invalid_arg("deploymentType must be cloud or selfhosted"),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn parse_target_type(value: &str) -> Result<TargetType> {
|
|
match value {
|
|
"user" => Ok(TargetType::User),
|
|
"workspace" => Ok(TargetType::Workspace),
|
|
"instance" => Ok(TargetType::Instance),
|
|
_ => invalid_arg("targetType must be user, workspace, or instance"),
|
|
}
|
|
}
|
|
|
|
pub(crate) 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");
|
|
};
|
|
i32::try_from(quantity)
|
|
.map(Some)
|
|
.map_err(|_| NapiError::new(Status::InvalidArg, "quantity is outside the supported integer range"))
|
|
}
|
|
|
|
pub(crate) fn entitlement_input_error(error: EntitlementInputError) -> NapiError {
|
|
let message = match error {
|
|
EntitlementInputError::InvalidQuantity => "quantity must be between 1 and 100000",
|
|
EntitlementInputError::SignedFieldsConflict => "signed commercial fields must come from license claims",
|
|
EntitlementInputError::SignedTargetMismatch => {
|
|
"signedPayload is only supported for selfhosted workspace entitlements"
|
|
}
|
|
EntitlementInputError::SelfHostedCommercialRequiresSignature => {
|
|
"selfhosted commercial entitlements require signedPayload"
|
|
}
|
|
EntitlementInputError::PlanTargetMismatch => "entitlement plan is not configurable for target type",
|
|
};
|
|
NapiError::new(Status::InvalidArg, message)
|
|
}
|
|
|
|
fn resolve_selfhost_license(input: ResolveEntitlementInput, now: DateTime<Utc>) -> Result<ResolvedEntitlement> {
|
|
let payload = input
|
|
.signed_payload
|
|
.ok_or_else(|| NapiError::new(Status::InvalidArg, "signedPayload is required"))?;
|
|
let Some(public_key) = input.public_key else {
|
|
return invalid_arg("publicKey is required for signed payload verification");
|
|
};
|
|
|
|
let normalized = match crate::license_import::normalize_license(payload.as_ref()) {
|
|
Ok(payload) => payload,
|
|
Err(error) if error.status == Status::GenericFailure => return Err(error),
|
|
Err(error) => return Ok(invalid_license(&error.reason, &error.reason)),
|
|
};
|
|
let claims = match LicenseVerifier::verify(normalized.as_ref(), &public_key, input.target_id.as_deref(), now) {
|
|
Ok(claims) => claims,
|
|
Err(error) => return Ok(invalid_license(error.code(), &error.to_string())),
|
|
};
|
|
let grant: AccessGrant = describe_plan(Plan::SelfHostedTeam, Some(claims.seat_quantity()))
|
|
.map_err(entitlement_input_error)?
|
|
.into();
|
|
let mut entitlement = active_with_grant(
|
|
grant.plan,
|
|
grant.quantity,
|
|
grant.limits,
|
|
grant.rights,
|
|
Some(claims.expires_at().to_rfc3339()),
|
|
);
|
|
entitlement.subject_id = Some(claims.license_id().to_string());
|
|
entitlement.target_id = Some(claims.workspace_id().to_string());
|
|
entitlement.issued_at = Some(claims.issued_at().to_rfc3339());
|
|
entitlement.recurring = claims.recurring().map(str::to_string);
|
|
entitlement.entity = Some(claims.audience().to_string());
|
|
entitlement.issuer = Some("affine".to_string());
|
|
Ok(entitlement)
|
|
}
|
|
|
|
fn active_with_grant(
|
|
plan: Plan,
|
|
quantity: Option<i32>,
|
|
limits: Limits,
|
|
rights: Rights,
|
|
expires_at: Option<String>,
|
|
) -> ResolvedEntitlement {
|
|
let quantity = quantity.filter(|_| matches!(plan, Plan::Team | Plan::SelfHostedTeam));
|
|
ResolvedEntitlement {
|
|
plan: plan.as_str().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(limits),
|
|
flags: flags(rights),
|
|
error_code: None,
|
|
error_message: None,
|
|
}
|
|
}
|
|
|
|
fn invalid_license(code: &str, message: &str) -> ResolvedEntitlement {
|
|
let plan = Plan::SelfHostedFree;
|
|
let access = describe_plan(plan, None).expect("self-hosted free plan is valid");
|
|
ResolvedEntitlement {
|
|
plan: plan.as_str().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(access.limits),
|
|
flags: flags(access.rights),
|
|
error_code: Some(code.to_string()),
|
|
error_message: Some(message.to_string()),
|
|
}
|
|
}
|
|
|
|
fn quota(limits: Limits) -> ResolvedQuota {
|
|
ResolvedQuota {
|
|
blob_limit: limits.blob_limit,
|
|
storage_quota: limits.storage_quota,
|
|
seat_limit: Some(limits.seat_limit),
|
|
seat_quota: limits.seat_quota,
|
|
history_period: limits.history_period,
|
|
copilot_action_limit: limits.copilot_action_limit,
|
|
}
|
|
}
|
|
|
|
fn flags(rights: Rights) -> HashMap<String, bool> {
|
|
let mut flags = HashMap::new();
|
|
flags.insert("unlimitedCopilot".to_string(), rights.unlimited_copilot);
|
|
flags.insert("copilotByok".to_string(), rights.copilot_byok);
|
|
flags.insert("commercial".to_string(), rights.commercial);
|
|
flags
|
|
}
|
|
|
|
pub(crate) 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)]
|
|
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
|
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
|
struct LicenseClaimsV1 {
|
|
format_version: u32,
|
|
license_id: String,
|
|
workspace_id: String,
|
|
audience: String,
|
|
plan: String,
|
|
seat_quantity: i32,
|
|
issued_at: String,
|
|
not_before: String,
|
|
expires_at: String,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct LicenseEnvelopeV1 {
|
|
claims: LicenseClaimsV1,
|
|
signature: String,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn signed_test_license(workspace_id: &str) -> (Vec<u8>, String) {
|
|
signed_test_license_with_id(workspace_id, &format!("license:{workspace_id}"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn signed_test_license_with_id(workspace_id: &str, license_id: &str) -> (Vec<u8>, String) {
|
|
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
|
use chrono::Duration;
|
|
use p256::{
|
|
ecdsa::{Signature, SigningKey, signature::Signer},
|
|
pkcs8::{DecodePrivateKey, EncodePublicKey, LineEnding},
|
|
};
|
|
|
|
let signing_key = SigningKey::from_pkcs8_pem(tests::TEST_PRIVATE_KEY).unwrap();
|
|
let now = Utc::now();
|
|
let claims = LicenseClaimsV1 {
|
|
format_version: 1,
|
|
license_id: license_id.to_string(),
|
|
workspace_id: workspace_id.to_string(),
|
|
audience: "affine-selfhost".to_string(),
|
|
plan: "selfhost_team".to_string(),
|
|
seat_quantity: 10,
|
|
issued_at: (now - Duration::minutes(1)).to_rfc3339(),
|
|
not_before: (now - Duration::minutes(1)).to_rfc3339(),
|
|
expires_at: (now + Duration::minutes(10)).to_rfc3339(),
|
|
};
|
|
let signature: Signature = signing_key.sign(&serde_json::to_vec(&claims).unwrap());
|
|
let payload = serde_json::to_vec(&LicenseEnvelopeV1 {
|
|
claims,
|
|
signature: BASE64.encode(signature.to_der()),
|
|
})
|
|
.unwrap();
|
|
let public_key = signing_key.verifying_key().to_public_key_pem(LineEnding::LF).unwrap();
|
|
(payload, public_key)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) mod tests {
|
|
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
|
use p256::ecdsa::Signature;
|
|
|
|
use super::*;
|
|
|
|
const TEST_WORKSPACE_ID: &str = "d6f52bc7-d62a-4822-804a-335fa7dfe5a6";
|
|
#[rustfmt::skip]
|
|
pub(crate) const TEST_PUBLIC_KEY: &str = "-----BEGIN PUBLIC KEY-----\n\
|
|
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEObwJiTmbui7rkWfPJ7Lozvuy2Rcl\n\
|
|
otcrb0V6dlS2ijKEShm7ZttTwQn08xzesdjX/AxpoR5X9yfoHkauIBuuMQ==\n\
|
|
-----END PUBLIC KEY-----";
|
|
#[rustfmt::skip]
|
|
pub(crate) const TEST_PRIVATE_KEY: &str = "-----BEGIN PRIVATE KEY-----\n\
|
|
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgsH+B50OQ7W85sBwV\n\
|
|
Vu1OkczX+OJICAwmwCMDMBhEXB+hRANCAAQ5vAmJOZu6LuuRZ88nsujO+7LZFyWi\n\
|
|
1ytvRXp2VLaKMoRKGbtm21PBCfTzHN6x2Nf8DGmhHlf3J+geRq4gG64x\n\
|
|
-----END PRIVATE 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,
|
|
now: "2026-05-14T00:00:00Z".to_string(),
|
|
}
|
|
}
|
|
|
|
fn signed_license(claims: LicenseClaimsV1) -> Vec<u8> {
|
|
use p256::{
|
|
ecdsa::{SigningKey, signature::Signer},
|
|
pkcs8::DecodePrivateKey,
|
|
};
|
|
|
|
let signing_key = SigningKey::from_pkcs8_pem(TEST_PRIVATE_KEY).unwrap();
|
|
let canonical = serde_json::to_vec(&claims).unwrap();
|
|
let signature: Signature = signing_key.sign(&canonical);
|
|
serde_json::to_vec(&LicenseEnvelopeV1 {
|
|
claims,
|
|
signature: BASE64.encode(signature.to_der()),
|
|
})
|
|
.unwrap()
|
|
}
|
|
|
|
fn claims() -> LicenseClaimsV1 {
|
|
LicenseClaimsV1 {
|
|
format_version: 1,
|
|
license_id: "license-id".to_string(),
|
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
|
audience: "affine-selfhost".to_string(),
|
|
plan: "selfhost_team".to_string(),
|
|
seat_quantity: 20,
|
|
issued_at: "2026-05-13T00:00:00Z".to_string(),
|
|
not_before: "2026-05-13T00:00:00Z".to_string(),
|
|
expires_at: "2026-05-15T00:00:00Z".to_string(),
|
|
}
|
|
}
|
|
|
|
fn license_input(payload: Vec<u8>, workspace_id: &str) -> ResolveEntitlementInput {
|
|
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(payload.into()),
|
|
public_key: Some(TEST_PUBLIC_KEY.to_string()),
|
|
now: "2026-05-14T00:00:00Z".to_string(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn maps_catalog_plan_to_napi_output() {
|
|
let resolved = resolve_entitlement_v1(input(Some("team"), Some(5))).unwrap();
|
|
assert!(resolved.valid);
|
|
assert_eq!(resolved.plan, "team");
|
|
assert_eq!(resolved.quantity, Some(5));
|
|
assert_eq!(resolved.quota.seat_limit, Some(5));
|
|
assert!(resolved.flags.get("commercial").copied().unwrap_or_default());
|
|
let mut ai = input(Some("ai"), None);
|
|
ai.target_type = "user".into();
|
|
let ai = resolve_entitlement_v1(ai).unwrap();
|
|
assert_eq!(ai.plan, "free");
|
|
assert_eq!(ai.quota.copilot_action_limit, None);
|
|
assert_eq!(ai.flags.get("copilotByok"), Some(&true));
|
|
}
|
|
|
|
#[test]
|
|
fn maps_input_and_domain_errors_to_invalid_arg() {
|
|
let mut invalid_deployment = input(Some("free"), None);
|
|
invalid_deployment.deployment_type = "local".to_string();
|
|
assert_eq!(
|
|
resolve_entitlement_v1(invalid_deployment).unwrap_err().status,
|
|
Status::InvalidArg
|
|
);
|
|
let mut unsigned_commercial = input(Some("team"), Some(5));
|
|
unsigned_commercial.deployment_type = "selfhosted".to_string();
|
|
assert_eq!(
|
|
resolve_entitlement_v1(unsigned_commercial).unwrap_err().status,
|
|
Status::InvalidArg
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn maps_signed_license_result() {
|
|
let valid = resolve_entitlement_v1(license_input(signed_license(claims()), TEST_WORKSPACE_ID)).unwrap();
|
|
assert!(valid.valid);
|
|
assert_eq!(valid.plan, "selfhost_team");
|
|
assert_eq!(valid.quantity, Some(20));
|
|
|
|
let mut preview = license_input(signed_license(claims()), TEST_WORKSPACE_ID);
|
|
preview.target_id = None;
|
|
let preview = resolve_entitlement_v1(preview).unwrap();
|
|
assert!(preview.valid);
|
|
assert_eq!(preview.target_id.as_deref(), Some(TEST_WORKSPACE_ID));
|
|
|
|
let mismatch = resolve_entitlement_v1(license_input(signed_license(claims()), "other-workspace")).unwrap();
|
|
assert!(!mismatch.valid);
|
|
assert_eq!(mismatch.error_code.as_deref(), Some("workspace_mismatch"));
|
|
}
|
|
|
|
#[test]
|
|
fn maps_license_issuance_types_and_errors() {
|
|
let payload = issue_license_v1(IssueLicenseInput {
|
|
license_id: "license-id".into(),
|
|
workspace_id: TEST_WORKSPACE_ID.into(),
|
|
seat_quantity: 20.0,
|
|
subscription_end: Some("2026-05-16T00:00:00Z".into()),
|
|
private_key: TEST_PRIVATE_KEY.into(),
|
|
now: "2026-05-14T00:00:00Z".into(),
|
|
})
|
|
.unwrap();
|
|
let resolved = resolve_entitlement_v1(license_input(payload.to_vec(), TEST_WORKSPACE_ID)).unwrap();
|
|
assert!(resolved.valid);
|
|
assert_eq!(resolved.quantity, Some(20));
|
|
|
|
let error = match issue_license_v1(IssueLicenseInput {
|
|
license_id: "license-id".into(),
|
|
workspace_id: TEST_WORKSPACE_ID.into(),
|
|
seat_quantity: 0.0,
|
|
subscription_end: None,
|
|
private_key: TEST_PRIVATE_KEY.into(),
|
|
now: "2026-05-14T00:00:00Z".into(),
|
|
}) {
|
|
Ok(_) => panic!("invalid quantity was accepted"),
|
|
Err(error) => error,
|
|
};
|
|
assert_eq!(error.status, Status::InvalidArg);
|
|
|
|
assert!(validate_license_seat_quantity_v1(1.5).is_err());
|
|
assert!(validate_license_seat_quantity_v1(4_294_967_297.0).is_err());
|
|
assert!(validate_license_seat_quantity_v1(-4_294_967_295.0).is_err());
|
|
}
|
|
}
|