use std::{collections::HashMap, fmt};
use serde::{Deserialize, Deserializer, Serialize};
use crate::{ClientTraffic, InboundProtocol};
pub(crate) fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de> + Default,
{
Option::<T>::deserialize(deserializer).map(Option::unwrap_or_default)
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct ClientReverse {
pub tag: String,
}
#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ClientConfig {
#[serde(rename = "id", skip_serializing_if = "String::is_empty")]
pub protocol_id: String,
pub security: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub password: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub flow: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reverse: Option<ClientReverse>,
#[serde(skip_serializing_if = "String::is_empty")]
pub auth: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub private_key: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub public_key: String,
#[serde(
rename = "allowedIPs",
skip_serializing_if = "Vec::is_empty",
deserialize_with = "deserialize_null_default"
)]
pub allowed_ips: Vec<String>,
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub allowed_ips_by_inbound: HashMap<i64, Vec<String>>,
#[serde(skip_serializing_if = "String::is_empty")]
pub pre_shared_key: String,
#[serde(skip_serializing_if = "is_zero_i32")]
pub keep_alive: i32,
#[serde(skip_serializing_if = "String::is_empty")]
pub forwarded_ports: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub secret: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub ad_tag: String,
pub email: String,
pub limit_ip: i32,
pub limit_hwid: i32,
#[serde(rename = "totalGB")]
pub total_gb: i64,
pub expiry_time: i64,
pub enable: bool,
pub tg_id: i64,
pub sub_id: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub group: String,
pub comment: String,
pub reset: i32,
pub reset_day: i32,
pub reset_max: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub traffic_reset: Option<crate::TrafficReset>,
#[serde(skip_serializing_if = "is_zero_i32")]
pub traffic_reset_day: i32,
#[serde(rename = "created_at", skip_serializing_if = "is_zero_i64")]
pub created_at: i64,
#[serde(rename = "updated_at", skip_serializing_if = "is_zero_i64")]
pub updated_at: i64,
}
impl ClientConfig {
pub fn new(email: impl Into<String>) -> Self {
Self {
email: email.into(),
security: "auto".to_owned(),
enable: true,
..Self::default()
}
}
}
impl fmt::Debug for ClientConfig {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClientConfig")
.field("protocol_id", &"[REDACTED]")
.field("security", &self.security)
.field("password", &"[REDACTED]")
.field("flow", &self.flow)
.field("reverse", &self.reverse)
.field("auth", &"[REDACTED]")
.field("private_key", &"[REDACTED]")
.field("public_key", &"[REDACTED]")
.field("allowed_ips", &self.allowed_ips)
.field("allowed_ips_by_inbound", &self.allowed_ips_by_inbound)
.field("pre_shared_key", &"[REDACTED]")
.field("keep_alive", &self.keep_alive)
.field("forwarded_ports", &self.forwarded_ports)
.field("secret", &"[REDACTED]")
.field("ad_tag", &"[REDACTED]")
.field("email", &self.email)
.field("limit_ip", &self.limit_ip)
.field("limit_hwid", &self.limit_hwid)
.field("total_gb", &self.total_gb)
.field("expiry_time", &self.expiry_time)
.field("enable", &self.enable)
.field("tg_id", &self.tg_id)
.field("sub_id", &"[REDACTED]")
.field("group", &self.group)
.field("comment", &self.comment)
.field("reset", &self.reset)
.field("reset_day", &self.reset_day)
.field("reset_max", &self.reset_max)
.field("traffic_reset", &self.traffic_reset)
.field("traffic_reset_day", &self.traffic_reset_day)
.field("created_at", &self.created_at)
.field("updated_at", &self.updated_at)
.finish()
}
}
#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ClientRecord {
pub id: i64,
pub email: String,
pub sub_id: String,
pub uuid: String,
pub password: String,
pub auth: String,
pub flow: String,
pub security: String,
pub reverse: Option<ClientReverse>,
pub private_key: String,
pub public_key: String,
#[serde(rename = "allowedIPs")]
pub allowed_ips: String,
pub pre_shared_key: String,
pub keep_alive: i32,
pub forwarded_ports: String,
pub secret: String,
pub ad_tag: String,
pub limit_ip: i32,
pub limit_hwid: i32,
#[serde(rename = "totalGB")]
pub total_gb: i64,
pub expiry_time: i64,
pub enable: bool,
pub tg_id: i64,
pub group: String,
pub comment: String,
pub reset: i32,
pub reset_day: i32,
pub reset_max: i32,
pub traffic_reset: String,
pub traffic_reset_day: i32,
pub created_at: i64,
pub updated_at: i64,
}
impl ClientRecord {
pub fn to_config(&self) -> ClientConfig {
ClientConfig {
protocol_id: self.uuid.clone(),
security: self.security.clone(),
password: self.password.clone(),
flow: self.flow.clone(),
reverse: self.reverse.clone(),
auth: self.auth.clone(),
private_key: self.private_key.clone(),
public_key: self.public_key.clone(),
allowed_ips: self
.allowed_ips
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect(),
allowed_ips_by_inbound: HashMap::new(),
pre_shared_key: self.pre_shared_key.clone(),
keep_alive: self.keep_alive,
forwarded_ports: self.forwarded_ports.clone(),
secret: self.secret.clone(),
ad_tag: self.ad_tag.clone(),
email: self.email.clone(),
limit_ip: self.limit_ip,
limit_hwid: self.limit_hwid,
total_gb: self.total_gb,
expiry_time: self.expiry_time,
enable: self.enable,
tg_id: self.tg_id,
sub_id: self.sub_id.clone(),
group: self.group.clone(),
comment: self.comment.clone(),
reset: self.reset,
reset_day: self.reset_day,
reset_max: self.reset_max,
traffic_reset: match self.traffic_reset.as_str() {
"hourly" => Some(crate::TrafficReset::Hourly),
"daily" => Some(crate::TrafficReset::Daily),
"weekly" => Some(crate::TrafficReset::Weekly),
"monthly" => Some(crate::TrafficReset::Monthly),
"never" => Some(crate::TrafficReset::Never),
_ => None,
},
traffic_reset_day: self.traffic_reset_day,
created_at: self.created_at,
updated_at: self.updated_at,
}
}
}
impl fmt::Debug for ClientRecord {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClientRecord")
.field("id", &self.id)
.field("email", &self.email)
.field("sub_id", &"[REDACTED]")
.field("uuid", &"[REDACTED]")
.field("password", &"[REDACTED]")
.field("auth", &"[REDACTED]")
.field("flow", &self.flow)
.field("security", &self.security)
.field("reverse", &self.reverse)
.field("private_key", &"[REDACTED]")
.field("public_key", &"[REDACTED]")
.field("allowed_ips", &self.allowed_ips)
.field("pre_shared_key", &"[REDACTED]")
.field("keep_alive", &self.keep_alive)
.field("forwarded_ports", &self.forwarded_ports)
.field("secret", &"[REDACTED]")
.field("ad_tag", &"[REDACTED]")
.field("limit_ip", &self.limit_ip)
.field("limit_hwid", &self.limit_hwid)
.field("total_gb", &self.total_gb)
.field("expiry_time", &self.expiry_time)
.field("enable", &self.enable)
.field("tg_id", &self.tg_id)
.field("group", &self.group)
.field("comment", &self.comment)
.field("reset", &self.reset)
.field("reset_day", &self.reset_day)
.field("reset_max", &self.reset_max)
.field("traffic_reset", &self.traffic_reset)
.field("traffic_reset_day", &self.traffic_reset_day)
.field("created_at", &self.created_at)
.field("updated_at", &self.updated_at)
.finish()
}
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(default, rename_all = "camelCase")]
pub struct ClientWithAttachments {
#[serde(flatten)]
pub client: ClientRecord,
#[serde(deserialize_with = "deserialize_null_default")]
pub inbound_ids: Vec<i64>,
pub traffic: Option<ClientTraffic>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ClientDetails {
pub client: ClientRecord,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub inbound_ids: Vec<i64>,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub external_links: Vec<ClientExternalLink>,
pub used_traffic: i64,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub tunnel_allowed_ips: HashMap<i64, Vec<String>>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ClientHwidDevice {
pub id: i64,
pub first_seen: i64,
pub last_seen: i64,
pub user_agent: String,
#[serde(rename = "deviceOs")]
pub device_os: String,
pub os_version: String,
pub device_model: String,
}
#[derive(Clone, Default, Deserialize, Eq, PartialEq)]
#[serde(default, rename_all = "camelCase")]
pub struct ClientSlim {
pub email: String,
pub sub_id: String,
pub enable: bool,
#[serde(rename = "totalGB")]
pub total_gb: i64,
pub expiry_time: i64,
pub limit_ip: i32,
pub reset: i32,
pub group: String,
pub comment: String,
#[serde(deserialize_with = "deserialize_null_default")]
pub inbound_ids: Vec<i64>,
pub traffic: Option<ClientTraffic>,
pub created_at: i64,
pub updated_at: i64,
}
impl fmt::Debug for ClientSlim {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClientSlim")
.field("email", &self.email)
.field("sub_id", &"[REDACTED]")
.field("enable", &self.enable)
.field("total_gb", &self.total_gb)
.field("expiry_time", &self.expiry_time)
.field("limit_ip", &self.limit_ip)
.field("reset", &self.reset)
.field("group", &self.group)
.field("comment", &self.comment)
.field("inbound_ids", &self.inbound_ids)
.field("traffic", &self.traffic)
.field("created_at", &self.created_at)
.field("updated_at", &self.updated_at)
.finish()
}
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(default, rename_all = "camelCase")]
pub struct ClientSummary {
pub total: u64,
pub active: u64,
pub online_count: u64,
pub depleted_count: u64,
pub expiring_count: u64,
pub deactive_count: u64,
#[serde(deserialize_with = "deserialize_null_default")]
pub online: Vec<String>,
#[serde(deserialize_with = "deserialize_null_default")]
pub depleted: Vec<String>,
#[serde(deserialize_with = "deserialize_null_default")]
pub expiring: Vec<String>,
#[serde(deserialize_with = "deserialize_null_default")]
pub deactive: Vec<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(default, rename_all = "camelCase")]
pub struct ClientPage {
#[serde(deserialize_with = "deserialize_null_default")]
pub items: Vec<ClientSlim>,
pub total: u64,
pub filtered: u64,
pub page: u32,
pub page_size: u16,
pub summary: ClientSummary,
#[serde(deserialize_with = "deserialize_null_default")]
pub groups: Vec<String>,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum ClientStatusFilter {
Active,
Deactive,
Depleted,
Expiring,
Online,
}
impl ClientStatusFilter {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Active => "active",
Self::Deactive => "deactive",
Self::Depleted => "depleted",
Self::Expiring => "expiring",
Self::Online => "online",
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum ClientSort {
Enable,
Email,
InboundCount,
Traffic,
Remaining,
ExpiryTime,
CreatedAt,
UpdatedAt,
LastOnline,
}
impl ClientSort {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Enable => "enable",
Self::Email => "email",
Self::InboundCount => "inboundIds",
Self::Traffic => "traffic",
Self::Remaining => "remaining",
Self::ExpiryTime => "expiryTime",
Self::CreatedAt => "createdAt",
Self::UpdatedAt => "updatedAt",
Self::LastOnline => "lastOnline",
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum SortOrder {
#[default]
Ascending,
Descending,
}
impl SortOrder {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Ascending => "ascend",
Self::Descending => "descend",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClientPageRequest {
pub page: u32,
pub page_size: u16,
pub search: String,
pub statuses: Vec<ClientStatusFilter>,
pub protocols: Vec<InboundProtocol>,
pub inbound_ids: Vec<i64>,
pub sort: Option<ClientSort>,
pub order: SortOrder,
pub expiry_from: Option<i64>,
pub expiry_to: Option<i64>,
pub usage_from: Option<i64>,
pub usage_to: Option<i64>,
pub auto_renew: Option<bool>,
pub has_telegram_id: Option<bool>,
pub has_comment: Option<bool>,
pub groups: Vec<String>,
}
impl Default for ClientPageRequest {
fn default() -> Self {
Self {
page: 1,
page_size: 25,
search: String::new(),
statuses: Vec::new(),
protocols: Vec::new(),
inbound_ids: Vec::new(),
sort: None,
order: SortOrder::Ascending,
expiry_from: None,
expiry_to: None,
usage_from: None,
usage_to: None,
auto_renew: None,
has_telegram_id: None,
has_comment: None,
groups: Vec::new(),
}
}
}
#[derive(Clone, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientCreateRequest {
pub client: ClientConfig,
pub inbound_ids: Vec<i64>,
}
impl ClientCreateRequest {
pub fn new(client: ClientConfig, inbound_ids: Vec<i64>) -> Self {
Self {
client,
inbound_ids,
}
}
}
impl fmt::Debug for ClientCreateRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClientCreateRequest")
.field("client", &self.client)
.field("inbound_ids", &self.inbound_ids)
.finish()
}
}
impl<'de> Deserialize<'de> for ClientCreateRequest {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Wire {
client: ClientConfig,
#[serde(default, deserialize_with = "deserialize_null_default")]
inbound_ids: Vec<i64>,
}
Wire::deserialize(deserializer).map(|wire| Self {
client: wire.client,
inbound_ids: wire.inbound_ids,
})
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(default, rename_all = "camelCase")]
pub struct ClientMutationStatus {
pub node_pending: bool,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum ClientExternalLinkKind {
#[default]
Link,
Subscription,
}
#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ClientExternalLinkInput {
pub kind: ClientExternalLinkKind,
pub value: String,
pub remark: String,
}
impl fmt::Debug for ClientExternalLinkInput {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClientExternalLinkInput")
.field("kind", &self.kind)
.field("value", &"[REDACTED]")
.field("remark", &self.remark)
.finish()
}
}
#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ClientExternalLink {
pub id: i64,
pub client_id: i64,
pub kind: ClientExternalLinkKind,
pub value: String,
pub remark: String,
pub sort_index: i32,
pub created_at: i64,
}
impl fmt::Debug for ClientExternalLink {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClientExternalLink")
.field("id", &self.id)
.field("client_id", &self.client_id)
.field("kind", &self.kind)
.field("value", &"[REDACTED]")
.field("remark", &self.remark)
.field("sort_index", &self.sort_index)
.field("created_at", &self.created_at)
.finish()
}
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum BulkFlowAdjustment {
#[default]
Unchanged,
Clear,
Vision,
VisionUdp443,
}
impl BulkFlowAdjustment {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Unchanged => "",
Self::Clear => "none",
Self::Vision => "xtls-rprx-vision",
Self::VisionUdp443 => "xtls-rprx-vision-udp443",
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct BulkAdjustRequest {
pub emails: Vec<String>,
pub add_days: i32,
pub add_bytes: i64,
pub flow: BulkFlowAdjustment,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
pub struct BulkClientIssue {
pub email: String,
pub reason: String,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
pub struct BulkAdjustResult {
pub adjusted: u64,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub skipped: Vec<BulkClientIssue>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
pub struct BulkCreateResult {
pub created: u64,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub skipped: Vec<BulkClientIssue>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
pub struct BulkDeleteResult {
pub deleted: u64,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub skipped: Vec<BulkClientIssue>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
pub struct BulkSetEnabledResult {
pub changed: u64,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub skipped: Vec<BulkClientIssue>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(default)]
pub struct BulkAttachResult {
#[serde(deserialize_with = "deserialize_null_default")]
pub attached: Vec<String>,
#[serde(deserialize_with = "deserialize_null_default")]
pub skipped: Vec<String>,
#[serde(deserialize_with = "deserialize_null_default")]
pub errors: Vec<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(default)]
pub struct BulkDetachResult {
#[serde(deserialize_with = "deserialize_null_default")]
pub detached: Vec<String>,
#[serde(deserialize_with = "deserialize_null_default")]
pub skipped: Vec<String>,
#[serde(deserialize_with = "deserialize_null_default")]
pub errors: Vec<String>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
pub struct DeletedCount {
pub deleted: u64,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
pub struct AffectedCount {
pub affected: u64,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
pub struct GroupName {
pub name: String,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(default, rename_all = "camelCase")]
pub struct GroupSummary {
pub name: String,
pub client_count: u64,
pub traffic_used: i64,
pub up: i64,
pub down: i64,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
pub struct ClientIpEntry {
pub ip: String,
pub timestamp: i64,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
pub struct ClientIpInfo {
pub ip: String,
pub time: String,
pub node: String,
}
pub type ClientsByGuid = HashMap<String, Vec<String>>;
pub type ActiveInboundsByGuid = HashMap<String, Vec<String>>;
pub type ClientIpsByGuid = HashMap<String, HashMap<String, Vec<ClientIpEntry>>>;
pub type LastOnlineByEmail = HashMap<String, i64>;
#[allow(clippy::trivially_copy_pass_by_ref)]
const fn is_zero_i32(value: &i32) -> bool {
*value == 0
}
#[allow(clippy::trivially_copy_pass_by_ref)]
const fn is_zero_i64(value: &i64) -> bool {
*value == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn record_conversion_preserves_exact_acronym_wire_fields() {
let record: ClientRecord = serde_json::from_value(serde_json::json!({
"id": 9,
"email": "alice",
"uuid": "uuid-secret",
"subId": "sub-secret",
"allowedIPs": "10.0.0.2/32, fd00::2/128",
"totalGB": 4096,
"enable": true,
"reverse": {"tag": "reverse-tag"}
}))
.unwrap();
let config = record.to_config();
let wire = serde_json::to_value(&config).unwrap();
assert_eq!(config.allowed_ips, ["10.0.0.2/32", "fd00::2/128"]);
assert_eq!(
wire["allowedIPs"],
serde_json::json!(["10.0.0.2/32", "fd00::2/128"])
);
assert_eq!(wire["totalGB"], 4096);
assert!(wire.get("allowedIps").is_none());
assert!(wire.get("totalGb").is_none());
}
#[test]
fn client_debug_redacts_protocol_and_subscription_secrets() {
let mut client = ClientConfig::new("alice");
client.protocol_id = "uuid-secret".to_owned();
client.password = "password-secret".to_owned();
client.auth = "auth-secret".to_owned();
client.private_key = "private-secret".to_owned();
client.pre_shared_key = "psk-secret".to_owned();
client.secret = "mtproto-secret".to_owned();
client.sub_id = "subscription-secret".to_owned();
let output = format!("{client:?}");
for secret in [
"uuid-secret",
"password-secret",
"auth-secret",
"private-secret",
"psk-secret",
"mtproto-secret",
"subscription-secret",
] {
assert!(!output.contains(secret));
}
assert!(output.contains("alice"));
let slim = ClientSlim {
email: "alice".to_owned(),
sub_id: "slim-subscription-secret".to_owned(),
..ClientSlim::default()
};
assert!(!format!("{slim:?}").contains("slim-subscription-secret"));
}
#[test]
fn nil_go_slices_decode_as_empty_rust_vectors() {
let result: BulkAttachResult = serde_json::from_value(serde_json::json!({
"attached": null,
"skipped": null,
"errors": null
}))
.unwrap();
let client: ClientWithAttachments = serde_json::from_value(serde_json::json!({
"email": "orphan",
"inboundIds": null
}))
.unwrap();
assert!(result.attached.is_empty());
assert!(result.skipped.is_empty());
assert!(result.errors.is_empty());
assert!(client.inbound_ids.is_empty());
}
#[test]
fn record_and_link_debug_views_redact_credentials() {
let record = ClientRecord {
email: "alice".into(),
uuid: "record-uuid-secret".into(),
password: "record-password-secret".into(),
auth: "record-auth-secret".into(),
private_key: "record-private-secret".into(),
public_key: "record-public-secret".into(),
pre_shared_key: "record-psk-secret".into(),
secret: "record-mtproto-secret".into(),
ad_tag: "record-ad-secret".into(),
sub_id: "record-sub-secret".into(),
..ClientRecord::default()
};
let record_debug = format!("{record:?}");
assert!(record_debug.contains("alice"));
for secret in [
"record-uuid-secret",
"record-password-secret",
"record-auth-secret",
"record-private-secret",
"record-public-secret",
"record-psk-secret",
"record-mtproto-secret",
"record-ad-secret",
"record-sub-secret",
] {
assert!(!record_debug.contains(secret));
}
let request = ClientCreateRequest::new(ClientConfig::new("bob"), vec![7, 9]);
assert!(format!("{request:?}").contains("bob"));
let input = ClientExternalLinkInput {
kind: ClientExternalLinkKind::Subscription,
value: "https://example.com/input-link-secret".into(),
remark: "primary".into(),
};
let link = ClientExternalLink {
id: 3,
client_id: 4,
kind: ClientExternalLinkKind::Link,
value: "vless://persisted-link-secret".into(),
remark: "backup".into(),
sort_index: 2,
created_at: 5,
};
assert!(!format!("{input:?}").contains("input-link-secret"));
assert!(!format!("{link:?}").contains("persisted-link-secret"));
}
#[test]
fn paging_and_bulk_enums_match_source_vocabulary() {
assert_eq!(
[
ClientStatusFilter::Active,
ClientStatusFilter::Deactive,
ClientStatusFilter::Depleted,
ClientStatusFilter::Expiring,
ClientStatusFilter::Online,
]
.map(ClientStatusFilter::as_str),
["active", "deactive", "depleted", "expiring", "online"]
);
assert_eq!(
[
ClientSort::Enable,
ClientSort::Email,
ClientSort::InboundCount,
ClientSort::Traffic,
ClientSort::Remaining,
ClientSort::ExpiryTime,
ClientSort::CreatedAt,
ClientSort::UpdatedAt,
ClientSort::LastOnline,
]
.map(ClientSort::as_str),
[
"enable",
"email",
"inboundIds",
"traffic",
"remaining",
"expiryTime",
"createdAt",
"updatedAt",
"lastOnline",
]
);
assert_eq!(SortOrder::Ascending.as_str(), "ascend");
assert_eq!(SortOrder::Descending.as_str(), "descend");
assert_eq!(
[
BulkFlowAdjustment::Unchanged,
BulkFlowAdjustment::Clear,
BulkFlowAdjustment::Vision,
BulkFlowAdjustment::VisionUdp443,
]
.map(BulkFlowAdjustment::as_str),
["", "none", "xtls-rprx-vision", "xtls-rprx-vision-udp443"]
);
}
#[test]
fn create_request_accepts_legacy_null_inbound_ids() {
let request: ClientCreateRequest = serde_json::from_value(serde_json::json!({
"client": {"email": "alice"},
"inboundIds": null
}))
.unwrap();
assert_eq!(request.client.email, "alice");
assert!(request.inbound_ids.is_empty());
}
}