mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-31 01:52:30 +00:00
Compare commits
19 Commits
fix/cli-mb
...
1.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95147a7824 | ||
|
|
b22811f78c | ||
|
|
fd61d013a2 | ||
|
|
3a950e7591 | ||
|
|
f17820bfa8 | ||
|
|
178b25d27d | ||
|
|
d160ca75f5 | ||
|
|
04136a4ae2 | ||
|
|
1d6f5d9a22 | ||
|
|
105a6d9b15 | ||
|
|
df440c8441 | ||
|
|
4116a59b79 | ||
|
|
609eee1b84 | ||
|
|
79b9f07888 | ||
|
|
ba28369202 | ||
|
|
ff64b66f79 | ||
|
|
a4f8e674c3 | ||
|
|
dde6b990da | ||
|
|
6b1f843bd5 |
10
Cargo.lock
generated
10
Cargo.lock
generated
@@ -293,7 +293,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bichon-admin"
|
||||
version = "1.0.2"
|
||||
version = "1.1.3"
|
||||
dependencies = [
|
||||
"bichon-core",
|
||||
"console",
|
||||
@@ -312,7 +312,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-cli"
|
||||
version = "1.0.2"
|
||||
version = "1.1.3"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bichon-core",
|
||||
@@ -338,7 +338,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-core"
|
||||
version = "1.0.2"
|
||||
version = "1.1.3"
|
||||
dependencies = [
|
||||
"async-imap",
|
||||
"base64 0.22.1",
|
||||
@@ -396,7 +396,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-server"
|
||||
version = "1.0.2"
|
||||
version = "1.1.3"
|
||||
dependencies = [
|
||||
"bichon-core",
|
||||
"bichon-smtp",
|
||||
@@ -421,7 +421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-smtp"
|
||||
version = "1.0.2"
|
||||
version = "1.1.3"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bichon-core",
|
||||
|
||||
@@ -11,7 +11,7 @@ members = [
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.0.2"
|
||||
version = "1.1.3"
|
||||
edition = "2021"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
@@ -271,6 +271,10 @@ All settings accept both CLI flags (`--bichon-http-port`) and environment variab
|
||||
> [!TIP]
|
||||
> Place `BICHON_INDEX_DIR` on fast SSD storage for responsive search, and `BICHON_DATA_DIR` on high-capacity HDD for cost-effective blob storage.
|
||||
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Bichon does NOT support writing data directly to a network file system (NFS, CIFS/SMB, etc.). All directories — `BICHON_ROOT_DIR`, `BICHON_DATA_DIR`, and `BICHON_INDEX_DIR` — must reside on a **local file system**; otherwise, data corruption may occur.
|
||||
|
||||
### Performance Tuning
|
||||
|
||||
| Variable | Default | Description |
|
||||
|
||||
@@ -60,3 +60,75 @@ pub fn determine_folder(labels_raw: &str) -> String {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use mail_parser::{HeaderValue, MessageParser};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn parse_x_gmail_labels(raw_message: &[u8]) -> Option<String> {
|
||||
// MessageParser::new() has an empty header_map so the hardcoded match at
|
||||
// parsers/header.rs:76 treats ALL unknown headers as raw (no RFC 2047
|
||||
// decoding). We need three things to get decoding:
|
||||
// 1. A non-empty header_map (so the else branch runs)
|
||||
// 2. default_header_text() so the fallback fn is parse_unstructured
|
||||
// 3. OR register X-Gmail-Labels explicitly via header_text()
|
||||
let message = MessageParser::new()
|
||||
.with_minimal_headers()
|
||||
.default_header_text()
|
||||
.parse(raw_message)?;
|
||||
let value: &HeaderValue<'_> = message.header("X-Gmail-Labels")?;
|
||||
value.as_text().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Construct a raw MIME message with RFC 2047 encoded X-Gmail-Labels,
|
||||
/// parse it, and verify the header is correctly decoded.
|
||||
fn build_email(x_gmail_labels: &str) -> Vec<u8> {
|
||||
format!(
|
||||
"From: sender@example.com\r\n\
|
||||
To: recipient@example.com\r\n\
|
||||
Subject: Test\r\n\
|
||||
X-Gmail-Labels: {}\r\n\
|
||||
\r\n\
|
||||
Body text here.\r\n",
|
||||
x_gmail_labels
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc2047_encoded_labels_are_decoded() {
|
||||
// Exactly the format the user reported: French Gmail labels
|
||||
let raw = build_email("=?UTF-8?Q?Corbeille?=, =?UTF-8?Q?Messages_archiv=C3=A9s?=");
|
||||
let labels = parse_x_gmail_labels(&raw).expect("failed to parse X-Gmail-Labels");
|
||||
|
||||
// mail-parser decodes RFC 2047 header values during initial parsing.
|
||||
// The decoded text should NOT contain raw =?UTF-8?Q?... sequences.
|
||||
assert!(!labels.contains("=?UTF-8"), "labels still encoded: {labels:?}");
|
||||
assert!(labels.contains("Corbeille"), "missing 'Corbeille': {labels:?}");
|
||||
assert!(
|
||||
labels.contains("archivés"),
|
||||
"missing decoded 'archivés': {labels:?}",
|
||||
);
|
||||
|
||||
// Full pipeline: decoded labels → determine_folder
|
||||
let folder = determine_folder(&labels);
|
||||
assert_eq!(folder, "Corbeille");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_ascii_labels_passthrough() {
|
||||
let raw = build_email("Inbox, Important");
|
||||
let labels = parse_x_gmail_labels(&raw).expect("failed to parse X-Gmail-Labels");
|
||||
assert_eq!(labels, "Inbox, Important");
|
||||
assert_eq!(determine_folder(&labels), "Important");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_x_gmail_labels_header() {
|
||||
let raw = b"From: sender@example.com\r\nTo: r@example.com\r\n\r\nBody.\r\n";
|
||||
let message = MessageParser::new().parse(raw.as_slice()).unwrap();
|
||||
assert!(message.header("X-Gmail-Labels").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ use bichon_core::envelope::meta::{parse_bichon_metadata, BichonMetadata};
|
||||
use console::style;
|
||||
use dialoguer::{theme::ColorfulTheme, Input};
|
||||
use dialoguer::{Confirm, Select};
|
||||
use mail_parser::parsers::MessageStream;
|
||||
use mail_parser::MessageParser;
|
||||
use reqwest::Client;
|
||||
|
||||
@@ -161,7 +160,11 @@ pub async fn run_import(
|
||||
continue;
|
||||
}
|
||||
|
||||
let message = match MessageParser::new().parse(body) {
|
||||
let message = match MessageParser::new()
|
||||
.with_minimal_headers()
|
||||
.default_header_text()
|
||||
.parse(body)
|
||||
{
|
||||
Some(msg) => msg,
|
||||
None => {
|
||||
eprintln!(
|
||||
@@ -181,15 +184,12 @@ pub async fn run_import(
|
||||
}
|
||||
|
||||
let get_default_folder = || {
|
||||
let gmail_labels = message.header_raw("X-Gmail-Labels").unwrap_or("INBOX");
|
||||
let text_cow = MessageStream::new(gmail_labels.as_bytes())
|
||||
.parse_unstructured()
|
||||
.into_text();
|
||||
let data: &str = match &text_cow {
|
||||
Some(c) => c.as_ref(),
|
||||
None => "INBOX",
|
||||
};
|
||||
determine_folder(data)
|
||||
let labels = message
|
||||
.header("X-Gmail-Labels")
|
||||
.and_then(|h| h.as_text())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "INBOX".to_string());
|
||||
determine_folder(&labels)
|
||||
};
|
||||
|
||||
let folder_name = if let Some(ref folder) = target_folder {
|
||||
|
||||
@@ -369,6 +369,10 @@ impl Account {
|
||||
new.folder_limit = Some(folder_limit);
|
||||
}
|
||||
|
||||
if let Some(account_name) = request.account_name {
|
||||
new.account_name = Some(account_name);
|
||||
}
|
||||
|
||||
if let Some(clear_folder_limit) = request.clear_folder_limit {
|
||||
if clear_folder_limit {
|
||||
new.folder_limit = None;
|
||||
|
||||
39
crates/core/src/cache/imap/download/rebuild.rs
vendored
39
crates/core/src/cache/imap/download/rebuild.rs
vendored
@@ -17,24 +17,20 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::{
|
||||
raise_error,
|
||||
{
|
||||
account::{
|
||||
migration::AccountModel,
|
||||
state::{DownloadState, DownloadStatus, FolderStatus},
|
||||
},
|
||||
cache::{
|
||||
imap::{
|
||||
download::flow::{
|
||||
fetch_and_save_by_date, fetch_and_save_full_mailbox, FetchDirection,
|
||||
},
|
||||
mailbox::MailBox,
|
||||
},
|
||||
SEMAPHORE,
|
||||
},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
store::tantivy::envelope::ENVELOPE_MANAGER,
|
||||
account::{
|
||||
migration::AccountModel,
|
||||
state::{DownloadState, DownloadStatus, FolderStatus},
|
||||
},
|
||||
cache::{
|
||||
imap::{
|
||||
download::flow::{fetch_and_save_by_date, fetch_and_save_full_mailbox, FetchDirection},
|
||||
mailbox::MailBox,
|
||||
},
|
||||
SEMAPHORE,
|
||||
},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
raise_error,
|
||||
store::tantivy::{attachment::ATTACHMENT_MANAGER, envelope::ENVELOPE_MANAGER},
|
||||
};
|
||||
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -91,7 +87,7 @@ pub async fn rebuild_cache(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
match fetch_and_save_full_mailbox(&account, &mailbox, token.clone()).await {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
@@ -204,7 +200,9 @@ pub async fn rebuild_mailbox_cache(
|
||||
ENVELOPE_MANAGER
|
||||
.delete_mailbox_envelopes(account.id, vec![local_mailbox.id])
|
||||
.await?;
|
||||
|
||||
ATTACHMENT_MANAGER
|
||||
.delete_mailbox_attachments(account.id, vec![local_mailbox.id])
|
||||
.await?;
|
||||
if remote_mailbox.exists == 0 {
|
||||
info!(
|
||||
"Account {}: Mailbox '{}' has no emails on the remote server. The mailbox is empty, no envelopes to fetch.",
|
||||
@@ -237,6 +235,9 @@ pub async fn rebuild_mailbox_cache_by_date(
|
||||
ENVELOPE_MANAGER
|
||||
.delete_mailbox_envelopes(account.id, vec![local_mailbox_id])
|
||||
.await?;
|
||||
ATTACHMENT_MANAGER
|
||||
.delete_mailbox_attachments(account.id, vec![local_mailbox_id])
|
||||
.await?;
|
||||
if remote.exists == 0 {
|
||||
info!(
|
||||
"Account {}: Mailbox '{}' has no emails on the remote server. The mailbox is empty, no envelopes to fetch.",
|
||||
|
||||
@@ -83,16 +83,11 @@ impl DashboardStats {
|
||||
|
||||
stat.email_count = ENVELOPE_MANAGER.total_emails(&authorized_ids)?;
|
||||
stat.attachment_count = ATTACHMENT_MANAGER.total_attachments(&authorized_ids)?;
|
||||
if has_all_accounts {
|
||||
stat.storage_usage_bytes = get_total_size(&DATA_DIR_MANAGER.storage_dir)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
stat.storage_usage_bytes = get_total_size(&DATA_DIR_MANAGER.storage_dir)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
stat.index_usage_bytes = get_total_size(&&DATA_DIR_MANAGER.envelope_dir)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
} else {
|
||||
stat.storage_usage_bytes = 0;
|
||||
stat.index_usage_bytes = 0;
|
||||
}
|
||||
stat.index_usage_bytes = get_total_size(&&DATA_DIR_MANAGER.envelope_dir)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
stat.system_version = bichon_version!().to_string();
|
||||
|
||||
|
||||
@@ -26,6 +26,6 @@ pub async fn delete_messages_impl(request: HashMap<u64, Vec<String>>) -> BichonR
|
||||
.delete_envelopes_multi_account(request.clone())
|
||||
.await?;
|
||||
ATTACHMENT_MANAGER
|
||||
.delete_envelopes_multi_account(request)
|
||||
.delete_attachments_multi_account(request)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -259,6 +259,12 @@ impl NewIndexWriter {
|
||||
.parse(eml_bytes)
|
||||
.ok_or_else(|| raise_error!("failed to parse eml".into(), ErrorCode::InternalError))?;
|
||||
|
||||
if message.parts.is_empty() {
|
||||
return Err(raise_error!(
|
||||
"Malformed or completely empty EML (no parts found)".into(),
|
||||
ErrorCode::InternalError
|
||||
));
|
||||
}
|
||||
// ── text / preview ────────────────────────────────────────────────
|
||||
let text = message
|
||||
.body_text(0)
|
||||
@@ -436,7 +442,7 @@ impl NewIndexWriter {
|
||||
.commit()
|
||||
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
||||
}
|
||||
println!("tantivy commit elasped: {:#?}", start.elapsed());
|
||||
println!("tantivy commit elapsed: {:#?}", start.elapsed());
|
||||
tracing::info!(count = self.pending, "committed tantivy batch");
|
||||
self.pending = 0;
|
||||
Ok(())
|
||||
|
||||
@@ -260,7 +260,7 @@ impl IndexManager {
|
||||
IndexRecordOption::Basic,
|
||||
);
|
||||
let envelope_id_query = TermQuery::new(
|
||||
Term::from_field_text(SchemaTools::attachment_fields().f_id, aid),
|
||||
Term::from_field_text(SchemaTools::attachment_fields().f_envelope_id, aid),
|
||||
IndexRecordOption::Basic,
|
||||
);
|
||||
let boolean_query = BooleanQuery::new(vec![
|
||||
@@ -633,7 +633,7 @@ impl IndexManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_envelopes_multi_account(
|
||||
pub async fn delete_attachments_multi_account(
|
||||
&self,
|
||||
deletes: HashMap<u64, Vec<String>>,
|
||||
) -> BichonResult<()> {
|
||||
|
||||
@@ -158,10 +158,10 @@ fn dedup_account(
|
||||
) -> BichonResult<u64> {
|
||||
let searcher = email_reader.searcher();
|
||||
let fields = SchemaTools::email_fields();
|
||||
eprintln!(
|
||||
"DEBUG dedup_account: entry account={account_id} f_id_field={:?} f_content_hash_field={:?}",
|
||||
fields.f_id, fields.f_content_hash
|
||||
);
|
||||
// eprintln!(
|
||||
// "DEBUG dedup_account: entry account={account_id} f_id_field={:?} f_content_hash_field={:?}",
|
||||
// fields.f_id, fields.f_content_hash
|
||||
// );
|
||||
let mut map: DedupMap = HashMap::new();
|
||||
|
||||
// ── Phase 1: build the dedup map via FAST column scans ──────────────────
|
||||
@@ -204,7 +204,11 @@ fn dedup_account(
|
||||
let ingest_at = ingest_col.values.get_val(doc_id);
|
||||
|
||||
// Read content_hash from the dictionary-encoded string column
|
||||
let hash_ord = hash_col.ords().values_for_doc(doc_id as u32).next().unwrap_or(0);
|
||||
let hash_ord = hash_col
|
||||
.ords()
|
||||
.values_for_doc(doc_id as u32)
|
||||
.next()
|
||||
.unwrap_or(0);
|
||||
let mut hash_buf = String::new();
|
||||
hash_col
|
||||
.ord_to_str(hash_ord, &mut hash_buf)
|
||||
@@ -212,16 +216,20 @@ fn dedup_account(
|
||||
let content_hash = hash_buf;
|
||||
|
||||
// Read f_id from the dictionary-encoded string column
|
||||
let id_ord = id_col.ords().values_for_doc(doc_id as u32).next().unwrap_or(0);
|
||||
let id_ord = id_col
|
||||
.ords()
|
||||
.values_for_doc(doc_id as u32)
|
||||
.next()
|
||||
.unwrap_or(0);
|
||||
let mut id_buf = String::new();
|
||||
id_col
|
||||
.ord_to_str(id_ord, &mut id_buf)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
let email_id = id_buf;
|
||||
|
||||
eprintln!(
|
||||
"DEBUG dedup_account: account={account_id} doc_id={doc_id} mailbox={mailbox_id} hash={content_hash:?} id={email_id:?} ingest_at={ingest_at}"
|
||||
);
|
||||
// eprintln!(
|
||||
// "DEBUG dedup_account: account={account_id} doc_id={doc_id} mailbox={mailbox_id} hash={content_hash:?} id={email_id:?} ingest_at={ingest_at}"
|
||||
// );
|
||||
|
||||
map.entry((mailbox_id, content_hash))
|
||||
.or_default()
|
||||
@@ -247,7 +255,11 @@ fn dedup_account(
|
||||
// uidvalidity, which is required for correct incremental sync.
|
||||
entries.sort_by_key(|e| std::cmp::Reverse(e.ingest_at));
|
||||
|
||||
eprintln!("DEBUG Phase2: key={_key:?} kept={} deleting={}", entries[0].email_id, entries.len() - 1);
|
||||
eprintln!(
|
||||
"DEBUG Phase2: key={_key:?} kept={} deleting={}",
|
||||
entries[0].email_id,
|
||||
entries.len() - 1
|
||||
);
|
||||
// Keep entries[0], soft-delete everything else via term query on f_id
|
||||
for entry in &entries[1..] {
|
||||
eprintln!(
|
||||
@@ -315,13 +327,14 @@ mod tests {
|
||||
|
||||
/// Collect non-deleted f_id values from the email index.
|
||||
fn surviving_email_ids(reader: &IndexReader) -> HashSet<String> {
|
||||
reader
|
||||
.reload()
|
||||
.expect("reader reload failed");
|
||||
reader.reload().expect("reader reload failed");
|
||||
let searcher = reader.searcher();
|
||||
let mut ids = HashSet::new();
|
||||
let segments = searcher.segment_readers();
|
||||
eprintln!("DEBUG surviving_email_ids: segment_count={}", segments.len());
|
||||
eprintln!(
|
||||
"DEBUG surviving_email_ids: segment_count={}",
|
||||
segments.len()
|
||||
);
|
||||
for (seg_idx, seg) in segments.iter().enumerate() {
|
||||
let id_col = seg
|
||||
.fast_fields()
|
||||
@@ -332,7 +345,11 @@ mod tests {
|
||||
eprintln!("DEBUG surviving_email_ids: seg={seg_idx} max_doc={max_doc}");
|
||||
for doc_id in 0..max_doc {
|
||||
let is_del = seg.is_deleted(doc_id);
|
||||
let ord = id_col.ords().values_for_doc(doc_id as u32).next().unwrap_or(0);
|
||||
let ord = id_col
|
||||
.ords()
|
||||
.values_for_doc(doc_id as u32)
|
||||
.next()
|
||||
.unwrap_or(0);
|
||||
let mut buf = String::new();
|
||||
id_col.ord_to_str(ord, &mut buf).unwrap();
|
||||
eprintln!("DEBUG surviving_email_ids: seg={seg_idx} doc_id={doc_id} is_deleted={is_del} ord={ord} buf={buf:?}");
|
||||
@@ -359,7 +376,11 @@ mod tests {
|
||||
if seg.is_deleted(doc_id) {
|
||||
continue;
|
||||
}
|
||||
let ord = id_col.ords().values_for_doc(doc_id as u32).next().unwrap_or(0);
|
||||
let ord = id_col
|
||||
.ords()
|
||||
.values_for_doc(doc_id as u32)
|
||||
.next()
|
||||
.unwrap_or(0);
|
||||
let mut buf = String::new();
|
||||
id_col.ord_to_str(ord, &mut buf).unwrap();
|
||||
ids.insert(buf);
|
||||
@@ -454,15 +475,17 @@ mod tests {
|
||||
|
||||
let email_r = email_idx.reader().unwrap();
|
||||
let survivors = surviving_email_ids(&email_r);
|
||||
let expected: HashSet<String> =
|
||||
expected_emails.iter().map(|s| s.to_string()).collect();
|
||||
let expected: HashSet<String> = expected_emails.iter().map(|s| s.to_string()).collect();
|
||||
assert_eq!(survivors, expected, "[{case}] email survivors mismatch");
|
||||
|
||||
let attach_r = attach_idx.reader().unwrap();
|
||||
let att_survivors = surviving_attachment_ids(&attach_r);
|
||||
let att_expected: HashSet<String> =
|
||||
expected_attachments.iter().map(|s| s.to_string()).collect();
|
||||
assert_eq!(att_survivors, att_expected, "[{case}] attachment survivors mismatch");
|
||||
assert_eq!(
|
||||
att_survivors, att_expected,
|
||||
"[{case}] attachment survivors mismatch"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,7 +517,7 @@ mod tests {
|
||||
add_attachment(af, aw, &format!("att-{i}"), &id, 1, 1);
|
||||
}
|
||||
},
|
||||
&["dup-2"], // ingest_at=400, the latest
|
||||
&["dup-2"], // ingest_at=400, the latest
|
||||
&["att-2"],
|
||||
)
|
||||
.await;
|
||||
@@ -591,7 +614,7 @@ mod tests {
|
||||
/// This test is read-only — it does not modify the index.
|
||||
#[test]
|
||||
fn inspect_production_duplicates() {
|
||||
let index_path = r"E:\db\data\bichon-indices\mail_metadata";
|
||||
let index_path = r"E:\bichon-data\bichon-indices\mail_metadata";
|
||||
let report_path = std::path::PathBuf::from(r"E:\bichon\dedup_report.txt");
|
||||
|
||||
let mut report = String::new();
|
||||
@@ -622,26 +645,21 @@ mod tests {
|
||||
let searcher = reader.searcher();
|
||||
|
||||
let mut total_docs = 0u64;
|
||||
let mut groups: std::collections::HashMap<u64, std::collections::HashMap<(u64, String), u64>> =
|
||||
std::collections::HashMap::new();
|
||||
let mut groups: std::collections::HashMap<
|
||||
u64,
|
||||
std::collections::HashMap<(u64, String), u64>,
|
||||
> = std::collections::HashMap::new();
|
||||
|
||||
for segment_reader in searcher.segment_readers() {
|
||||
let account_col = segment_reader
|
||||
.fast_fields()
|
||||
.u64(F_ACCOUNT_ID)
|
||||
.unwrap();
|
||||
let mailbox_col = segment_reader
|
||||
.fast_fields()
|
||||
.u64(F_MAILBOX_ID)
|
||||
.unwrap();
|
||||
let hash_col = match segment_reader
|
||||
.fast_fields()
|
||||
.str(F_CONTENT_HASH)
|
||||
.unwrap()
|
||||
{
|
||||
let account_col = segment_reader.fast_fields().u64(F_ACCOUNT_ID).unwrap();
|
||||
let mailbox_col = segment_reader.fast_fields().u64(F_MAILBOX_ID).unwrap();
|
||||
let hash_col = match segment_reader.fast_fields().str(F_CONTENT_HASH).unwrap() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
let _ = writeln!(report, "Segment has no FAST str column for content_hash, skipping");
|
||||
let _ = writeln!(
|
||||
report,
|
||||
"Segment has no FAST str column for content_hash, skipping"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -655,7 +673,11 @@ mod tests {
|
||||
let account_id = account_col.values.get_val(doc_id);
|
||||
let mailbox_id = mailbox_col.values.get_val(doc_id);
|
||||
|
||||
let hash_ord = hash_col.ords().values_for_doc(doc_id as u32).next().unwrap_or(0);
|
||||
let hash_ord = hash_col
|
||||
.ords()
|
||||
.values_for_doc(doc_id as u32)
|
||||
.next()
|
||||
.unwrap_or(0);
|
||||
let mut hash_buf = String::new();
|
||||
hash_col.ord_to_str(hash_ord, &mut hash_buf).unwrap();
|
||||
let content_hash = hash_buf;
|
||||
|
||||
@@ -39,6 +39,7 @@ use crate::{
|
||||
blob::BLOB_MANAGER,
|
||||
envelope::Envelope,
|
||||
tantivy::{
|
||||
attachment::ATTACHMENT_MANAGER,
|
||||
fatal_commit,
|
||||
fields::{
|
||||
F_ACCOUNT_ID, F_DATE, F_FROM, F_ID, F_REGULAR_ATTACHMENT_COUNT, F_SIZE, F_TAGS,
|
||||
@@ -727,6 +728,10 @@ impl IndexManager {
|
||||
.commit()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
ATTACHMENT_MANAGER
|
||||
.delete_account_attachments(account_id)
|
||||
.await?;
|
||||
|
||||
if !eml_content_hashes.is_empty() || !attachments_content_hashes.is_empty() {
|
||||
self.cleanup_unused_content(eml_content_hashes, attachments_content_hashes)?;
|
||||
}
|
||||
|
||||
@@ -26,10 +26,10 @@ use std::sync::LazyLock;
|
||||
|
||||
use crate::error::code::ErrorCode;
|
||||
use crate::error::BichonResult;
|
||||
use crate::settings::cli::SETTINGS;
|
||||
use crate::raise_error;
|
||||
use crate::settings::cli::SETTINGS;
|
||||
|
||||
static ENCRYPT_PASSWORD: LazyLock<String> = LazyLock::new(|| {
|
||||
pub static ENCRYPT_PASSWORD: LazyLock<String> = LazyLock::new(|| {
|
||||
if let Some(file_path) = &SETTINGS.bichon_encrypt_password_file {
|
||||
return fs::read_to_string(file_path)
|
||||
.expect("failed to read the file with the encrypt password")
|
||||
@@ -102,7 +102,10 @@ pub fn internal_encrypt_string(
|
||||
Ok(general_purpose::URL_SAFE.encode(&result))
|
||||
}
|
||||
|
||||
pub fn internal_decrypt_string(password: &str, data: &str) -> Result<String, ring::error::Unspecified> {
|
||||
pub fn internal_decrypt_string(
|
||||
password: &str,
|
||||
data: &str,
|
||||
) -> Result<String, ring::error::Unspecified> {
|
||||
let data = general_purpose::URL_SAFE
|
||||
.decode(data)
|
||||
.map_err(|_| ring::error::Unspecified)?;
|
||||
@@ -146,8 +149,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_wrong_password_fails() {
|
||||
let encrypted =
|
||||
internal_encrypt_string("correct_password", "secret").unwrap();
|
||||
let encrypted = internal_encrypt_string("correct_password", "secret").unwrap();
|
||||
assert!(internal_decrypt_string("wrong_password", &encrypted).is_err());
|
||||
}
|
||||
|
||||
|
||||
@@ -73,8 +73,9 @@ async fn main() -> BichonResult<()> {
|
||||
Ok(false) => {
|
||||
error!("Incompatible data format detected.");
|
||||
error!("Your data was created by an older version of Bichon and must be migrated before use.");
|
||||
error!("Please stop the Bichon v0.3.7 service before migration.");
|
||||
error!("Please run: bichon-admin");
|
||||
error!("Documentation: https://github.com/rustmailer/bichon/wiki/migration");
|
||||
error!("Documentation: https://github.com/rustmailer/bichon/wiki/Bichon-Data-Migration:-v0.3.7-%E2%86%92-v1.0");
|
||||
return Err(raise_error!(
|
||||
"Legacy data layout detected".into(),
|
||||
ErrorCode::InternalError
|
||||
@@ -202,7 +203,10 @@ mod api_tests {
|
||||
.map(|v| v.object().get("name").string())
|
||||
.collect();
|
||||
|
||||
assert!(tag_names.contains(&"AccessToken"), "missing AccessToken tag");
|
||||
assert!(
|
||||
tag_names.contains(&"AccessToken"),
|
||||
"missing AccessToken tag"
|
||||
);
|
||||
assert!(tag_names.contains(&"Attachment"), "missing Attachment tag");
|
||||
assert!(tag_names.contains(&"AutoConfig"), "missing AutoConfig tag");
|
||||
assert!(tag_names.contains(&"Account"), "missing Account tag");
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
"preview": "vite preview",
|
||||
"format:check": "prettier --check .",
|
||||
"format": "prettier --write .",
|
||||
"knip": "knip"
|
||||
"knip": "knip",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
@@ -81,6 +84,9 @@
|
||||
"@tanstack/react-query-devtools": "^5.62.3",
|
||||
"@tanstack/router-devtools": "^1.86.1",
|
||||
"@tanstack/router-plugin": "^1.86.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
@@ -88,18 +94,22 @@
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react-swc": "^3.7.2",
|
||||
"@vitest/coverage-v8": "^4.1.7",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.13.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"knip": "^5.41.1",
|
||||
"msw": "^2.14.6",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.17.0",
|
||||
"vite": "^6.0.11"
|
||||
"vite": "^6.0.11",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
1192
web/pnpm-lock.yaml
generated
1192
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -202,7 +202,7 @@ const Sidebar = React.forwardRef<
|
||||
<SheetContent
|
||||
data-sidebar='sidebar'
|
||||
data-mobile='true'
|
||||
className='w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden'
|
||||
className='w-[--sidebar-width] !bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden'
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getAccountSchema } from '../schema'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
const baseData = {
|
||||
email: 'test@example.com',
|
||||
imap: {
|
||||
host: 'imap.example.com',
|
||||
port: 993,
|
||||
encryption: 'Ssl' as const,
|
||||
auth: {
|
||||
auth_type: 'Password' as const,
|
||||
password: 'mypassword',
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
use_dangerous: false,
|
||||
download_interval_min: 60,
|
||||
download_batch_size: 30,
|
||||
auto_download_new_mailboxes: true,
|
||||
}
|
||||
|
||||
describe('Account Schema - date_since validation', () => {
|
||||
const schema = getAccountSchema(false, t)
|
||||
|
||||
it('accepts fixed date_since', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_since: { fixed: '2024-01-01' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts relative date_since', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_since: { relative: { unit: 'Months', value: 6 } },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts undefined date_since', () => {
|
||||
const result = schema.safeParse(baseData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects relative date_since with value 0', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_since: { relative: { unit: 'Months', value: 0 } },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects relative date_since with negative value', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_since: { relative: { unit: 'Months', value: -1 } },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects relative date_since with non-integer value', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_since: { relative: { unit: 'Months', value: 1.5 } },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects fixed date_since with empty string', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_since: { fixed: '' },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Account Schema - date_before validation', () => {
|
||||
const schema = getAccountSchema(false, t)
|
||||
|
||||
it('accepts valid date_before', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_before: { unit: 'Days', value: 30 },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts undefined date_before', () => {
|
||||
const result = schema.safeParse(baseData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects date_before with value 0', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_before: { unit: 'Days', value: 0 },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Account Schema - use_dangerous and enabled flags', () => {
|
||||
const schema = getAccountSchema(false, t)
|
||||
|
||||
it('accepts use_dangerous: true', () => {
|
||||
const result = schema.safeParse({ ...baseData, use_dangerous: true })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts enabled: false', () => {
|
||||
const result = schema.safeParse({ ...baseData, enabled: false })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts auto_download_new_mailboxes: false', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
auto_download_new_mailboxes: false,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Account Schema - missing required nested fields', () => {
|
||||
const schema = getAccountSchema(false, t)
|
||||
|
||||
it('rejects missing imap entirely', () => {
|
||||
const { imap, ...noImap } = baseData
|
||||
const result = schema.safeParse(noImap)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing imap.auth', () => {
|
||||
const { auth, ...noAuth } = baseData.imap
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
imap: noAuth,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
324
web/src/features/accounts/components/__tests__/schema.test.ts
Normal file
324
web/src/features/accounts/components/__tests__/schema.test.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getAccountSchema, getAuthConfigSchema } from '../schema'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
const validAccountData = {
|
||||
email: 'user@example.com',
|
||||
imap: {
|
||||
host: 'imap.example.com',
|
||||
port: 993,
|
||||
encryption: 'Ssl' as const,
|
||||
auth: {
|
||||
auth_type: 'Password' as const,
|
||||
password: 'mypassword',
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
use_dangerous: false,
|
||||
download_interval_min: 60,
|
||||
download_batch_size: 30,
|
||||
auto_download_new_mailboxes: true,
|
||||
}
|
||||
|
||||
describe('Account Form Schema', () => {
|
||||
describe('email field', () => {
|
||||
const schema = getAccountSchema(false, t)
|
||||
|
||||
it('rejects empty email', () => {
|
||||
const result = schema.safeParse({ ...validAccountData, email: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid email format', () => {
|
||||
const result = schema.safeParse({
|
||||
...validAccountData,
|
||||
email: 'not-an-email',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects email without @', () => {
|
||||
const result = schema.safeParse({
|
||||
...validAccountData,
|
||||
email: 'username',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid email', () => {
|
||||
const result = schema.safeParse(validAccountData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('imap.host field', () => {
|
||||
it('rejects empty IMAP host', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, host: '' },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid hostname', () => {
|
||||
const result = getAccountSchema(false, t).safeParse(validAccountData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts IP address as host', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, host: '192.168.1.1' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('imap.port field', () => {
|
||||
it('accepts port 993 (standard IMAP SSL)', () => {
|
||||
const result = getAccountSchema(false, t).safeParse(validAccountData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts port 143 (standard IMAP)', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, port: 143 },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts port 0 (auto-detect)', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, port: 0 },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects negative port', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, port: -1 },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects port > 65535', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, port: 99999 },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects non-integer port', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, port: 993.5 },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('imap.encryption field', () => {
|
||||
it('accepts Ssl', () => {
|
||||
const result = getAccountSchema(false, t).safeParse(validAccountData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts StartTls', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, encryption: 'StartTls' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts None', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, encryption: 'None' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid encryption value', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, encryption: 'TLS' },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('download_interval_min field', () => {
|
||||
it('rejects value less than 10', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_interval_min: 5,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts value of exactly 10', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_interval_min: 10,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-integer value', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_interval_min: 30.5,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('download_batch_size field', () => {
|
||||
it('rejects value less than 10', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_batch_size: 5,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects value greater than 200', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_batch_size: 500,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts value of exactly 10', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_batch_size: 10,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts value of exactly 200', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_batch_size: 200,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('folder_limit field', () => {
|
||||
it('accepts undefined folder_limit', () => {
|
||||
const result = getAccountSchema(false, t).safeParse(validAccountData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts null folder_limit', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
folder_limit: null,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects folder_limit less than 100', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
folder_limit: 50,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts folder_limit of exactly 100', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
folder_limit: 100,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('account_name and login_name fields', () => {
|
||||
it('accepts undefined account_name and login_name', () => {
|
||||
const result = getAccountSchema(false, t).safeParse(validAccountData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts provided account_name', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
account_name: 'My Work Email',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts provided login_name', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
login_name: 'username',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Auth Config Schema (password validation)', () => {
|
||||
describe('when creating (isEdit = false)', () => {
|
||||
const schema = getAuthConfigSchema(false, t)
|
||||
|
||||
it('requires password when auth_type is Password', () => {
|
||||
const result = schema.safeParse({
|
||||
auth_type: 'Password',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('requires password when auth_type is Password and password undefined', () => {
|
||||
const result = schema.safeParse({
|
||||
auth_type: 'Password',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid password with Password auth', () => {
|
||||
const result = schema.safeParse({
|
||||
auth_type: 'Password',
|
||||
password: 'mypassword',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('does not require password when auth_type is OAuth2', () => {
|
||||
const result = schema.safeParse({
|
||||
auth_type: 'OAuth2',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when editing (isEdit = true)', () => {
|
||||
const schema = getAuthConfigSchema(true, t)
|
||||
|
||||
it('does not require password even with Password auth', () => {
|
||||
const result = schema.safeParse({
|
||||
auth_type: 'Password',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts with undefined password', () => {
|
||||
const result = schema.safeParse({
|
||||
auth_type: 'Password',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,6 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form } from '@/components/ui/form';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
@@ -35,112 +34,9 @@ import { ToastAction } from '@/components/ui/toast';
|
||||
import { AxiosError } from 'axios';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAccountSchema, type AccountFormValues } from './schema';
|
||||
|
||||
const encryptionSchema = z.union([
|
||||
z.literal('Ssl'),
|
||||
z.literal('StartTls'),
|
||||
z.literal('None'),
|
||||
]);
|
||||
|
||||
const authTypeSchema = z.union([
|
||||
z.literal('Password'),
|
||||
z.literal('OAuth2'),
|
||||
]);
|
||||
|
||||
const getAuthConfigSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
z.object({
|
||||
auth_type: authTypeSchema,
|
||||
password: z.string().optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
if (data.auth_type === 'Password' && !isEdit) {
|
||||
return !!data.password?.trim();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t('validation.passwordRequired'),
|
||||
path: ['password'],
|
||||
}
|
||||
);
|
||||
|
||||
const getImapConfigSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
z.object({
|
||||
host: z.string({ required_error: t('validation.imapHostRequired') }).min(1, { message: t('validation.imapHostCannotBeEmpty') }),
|
||||
port: z.number().int().min(0, { message: t('validation.imapPortMustBePositive') }).max(65535, { message: t('validation.imapPortMustBeLessThan65536') }),
|
||||
encryption: encryptionSchema,
|
||||
auth: getAuthConfigSchema(isEdit, t),
|
||||
use_proxy: z.number().optional(),
|
||||
});
|
||||
|
||||
const getRelativeDateSchema = (t: (key: string) => string) => z.object({
|
||||
unit: z.enum(["Days", "Months", "Years"], { message: t('accounts.selectUnit') }),
|
||||
value: z.number({ message: t('accounts.enterValue') }).int().min(1, t('accounts.mustBeAtLeast1')),
|
||||
});
|
||||
|
||||
const getDateSelectionSchema = (t: (key: string) => string) => z.union([
|
||||
z.object({ fixed: z.string({ message: t('accounts.selectDate') }) }),
|
||||
z.object({ relative: getRelativeDateSchema(t) }),
|
||||
z.undefined(),
|
||||
]);
|
||||
|
||||
export type Account = {
|
||||
login_name?: string;
|
||||
account_name?: string;
|
||||
email: string;
|
||||
imap: {
|
||||
host: string;
|
||||
port: number;
|
||||
encryption: 'Ssl' | 'StartTls' | 'None';
|
||||
auth: {
|
||||
auth_type: 'Password' | 'OAuth2';
|
||||
password?: string;
|
||||
};
|
||||
use_proxy?: number;
|
||||
};
|
||||
enabled: boolean;
|
||||
use_dangerous: boolean;
|
||||
date_since?: {
|
||||
fixed?: string;
|
||||
relative?: {
|
||||
unit?: 'Days' | 'Months' | 'Years';
|
||||
value?: number;
|
||||
};
|
||||
};
|
||||
date_before?: {
|
||||
unit?: 'Days' | 'Months' | 'Years';
|
||||
value?: number;
|
||||
};
|
||||
folder_limit?: number;
|
||||
download_interval_min: number;
|
||||
download_batch_size: number;
|
||||
auto_download_new_mailboxes: boolean;
|
||||
};
|
||||
|
||||
const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
z.object({
|
||||
account_name: z.string().optional(),
|
||||
login_name: z.string().optional(),
|
||||
email: z.string({ required_error: t('validation.emailRequired') }).email({ message: t('validation.invalidEmail') }),
|
||||
imap: getImapConfigSchema(isEdit, t),
|
||||
enabled: z.boolean(),
|
||||
use_dangerous: z.boolean(),
|
||||
date_since: getDateSelectionSchema(t).optional(),
|
||||
date_before: getRelativeDateSchema(t).optional(),
|
||||
folder_limit: z
|
||||
.number({ invalid_type_error: t('validation.folderLimitMustBeNumber') })
|
||||
.int()
|
||||
.min(100, { message: t('validation.folderLimitMustBeAtLeast100') })
|
||||
.nullable()
|
||||
.optional(),
|
||||
download_interval_min: z.number({ invalid_type_error: t('validation.incrementalSyncMustBeNumber') }).int().min(10, { message: t('validation.incrementalSyncMustBeAtLeast10') }),
|
||||
download_batch_size: z
|
||||
.number({ invalid_type_error: t('validation.singleRequestBatchSizeMustBeNumber') })
|
||||
.int()
|
||||
.min(10, { message: t('validation.singleRequestBatchSizeTooSmall') })
|
||||
.max(200, { message: t('validation.singleRequestBatchSizeTooLarge') }),
|
||||
auto_download_new_mailboxes: z.boolean(),
|
||||
});
|
||||
export type Account = AccountFormValues;
|
||||
|
||||
type Step = {
|
||||
id: `step-${number}`;
|
||||
@@ -205,6 +101,7 @@ const mapCurrentRowToFormValues = (currentRow: AccountModel): Account => {
|
||||
}
|
||||
|
||||
return {
|
||||
account_name: currentRow.account_name ?? undefined,
|
||||
login_name: currentRow.login_name ?? undefined,
|
||||
email: currentRow.email,
|
||||
imap,
|
||||
|
||||
114
web/src/features/accounts/components/schema.ts
Normal file
114
web/src/features/accounts/components/schema.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const encryptionSchema = z.union([
|
||||
z.literal('Ssl'),
|
||||
z.literal('StartTls'),
|
||||
z.literal('None'),
|
||||
])
|
||||
|
||||
const authTypeSchema = z.union([
|
||||
z.literal('Password'),
|
||||
z.literal('OAuth2'),
|
||||
])
|
||||
|
||||
export const getAuthConfigSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
z
|
||||
.object({
|
||||
auth_type: authTypeSchema,
|
||||
password: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.auth_type === 'Password' && !isEdit) {
|
||||
return !!data.password?.trim()
|
||||
}
|
||||
return true
|
||||
},
|
||||
{
|
||||
message: t('validation.passwordRequired'),
|
||||
path: ['password'],
|
||||
}
|
||||
)
|
||||
|
||||
export const getImapConfigSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
z.object({
|
||||
host: z
|
||||
.string({ required_error: t('validation.imapHostRequired') })
|
||||
.min(1, { message: t('validation.imapHostCannotBeEmpty') }),
|
||||
port: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0, { message: t('validation.imapPortMustBePositive') })
|
||||
.max(65535, { message: t('validation.imapPortMustBeLessThan65536') }),
|
||||
encryption: encryptionSchema,
|
||||
auth: getAuthConfigSchema(isEdit, t),
|
||||
use_proxy: z.number().optional(),
|
||||
})
|
||||
|
||||
const relativeDateSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
unit: z.enum(['Days', 'Months', 'Years'], {
|
||||
message: t('accounts.selectUnit'),
|
||||
}),
|
||||
value: z
|
||||
.number({ message: t('accounts.enterValue') })
|
||||
.int()
|
||||
.min(1, t('accounts.mustBeAtLeast1')),
|
||||
})
|
||||
|
||||
const dateSelectionSchema = (t: (key: string) => string) =>
|
||||
z
|
||||
.object({
|
||||
fixed: z
|
||||
.string({ message: t('accounts.selectDate') })
|
||||
.min(1, { message: t('accounts.selectDate') })
|
||||
.optional(),
|
||||
relative: relativeDateSchema(t).optional(),
|
||||
})
|
||||
.optional()
|
||||
|
||||
export const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
z.object({
|
||||
account_name: z.string().optional(),
|
||||
login_name: z.string().optional(),
|
||||
email: z
|
||||
.string({ required_error: t('validation.emailRequired') })
|
||||
.email({ message: t('validation.invalidEmail') }),
|
||||
imap: getImapConfigSchema(isEdit, t),
|
||||
enabled: z.boolean(),
|
||||
use_dangerous: z.boolean(),
|
||||
date_since: dateSelectionSchema(t).optional(),
|
||||
date_before: relativeDateSchema(t).optional(),
|
||||
folder_limit: z
|
||||
.number({ invalid_type_error: t('validation.folderLimitMustBeNumber') })
|
||||
.int()
|
||||
.min(100, { message: t('validation.folderLimitMustBeAtLeast100') })
|
||||
.nullable()
|
||||
.optional(),
|
||||
download_interval_min: z
|
||||
.number({
|
||||
invalid_type_error: t('validation.incrementalSyncMustBeNumber'),
|
||||
})
|
||||
.int()
|
||||
.min(10, {
|
||||
message: t('validation.incrementalSyncMustBeAtLeast10'),
|
||||
}),
|
||||
download_batch_size: z
|
||||
.number({
|
||||
invalid_type_error: t(
|
||||
'validation.singleRequestBatchSizeMustBeNumber'
|
||||
),
|
||||
})
|
||||
.int()
|
||||
.min(10, {
|
||||
message: t('validation.singleRequestBatchSizeTooSmall'),
|
||||
})
|
||||
.max(200, {
|
||||
message: t('validation.singleRequestBatchSizeTooLarge'),
|
||||
}),
|
||||
auto_download_new_mailboxes: z.boolean(),
|
||||
})
|
||||
|
||||
export type AccountFormValues = z.infer<
|
||||
ReturnType<typeof getAccountSchema>
|
||||
>
|
||||
81
web/src/features/auth/__tests__/schema.test.ts
Normal file
81
web/src/features/auth/__tests__/schema.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getFormSchema } from '../schema'
|
||||
|
||||
// Simple mock t function that returns the key
|
||||
const t = (key: string, _options?: Record<string, any>) => key
|
||||
|
||||
describe('Login Form Schema', () => {
|
||||
const schema = getFormSchema(t)
|
||||
|
||||
describe('username field', () => {
|
||||
it('rejects empty username', () => {
|
||||
const result = schema.safeParse({ username: '', password: 'abcd' })
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const usernameErrors = result.error.issues.filter(
|
||||
(i) => i.path[0] === 'username'
|
||||
)
|
||||
expect(usernameErrors.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts valid username with password', () => {
|
||||
const result = schema.safeParse({ username: 'admin', password: 'pass1234' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts email as username', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'user@example.com',
|
||||
password: 'mypassword',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('password field', () => {
|
||||
it('rejects empty password', () => {
|
||||
const result = schema.safeParse({ username: 'admin', password: '' })
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const passwordErrors = result.error.issues.filter(
|
||||
(i) => i.path[0] === 'password'
|
||||
)
|
||||
expect(passwordErrors.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects password shorter than 4 characters', () => {
|
||||
const result = schema.safeParse({ username: 'admin', password: 'ab' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts password of exactly 4 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'admin',
|
||||
password: 'abcd',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts long password', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'admin',
|
||||
password: 'a'.repeat(256),
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('missing fields', () => {
|
||||
it('rejects empty object', () => {
|
||||
const result = schema.safeParse({})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects object with only username', () => {
|
||||
const result = schema.safeParse({ username: 'admin' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
16
web/src/features/auth/schema.ts
Normal file
16
web/src/features/auth/schema.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const getFormSchema = (
|
||||
t: (key: string, options?: Record<string, any>) => string
|
||||
) =>
|
||||
z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(1, { message: t('validation.pleaseEnterUsernameOrEmail') }),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, { message: t('validation.pleaseEnterPassword') })
|
||||
.min(4, { message: t('validation.passwordMinLength', { min: 4 }) }),
|
||||
})
|
||||
|
||||
export type LoginFormValues = z.infer<ReturnType<typeof getFormSchema>>
|
||||
@@ -18,10 +18,10 @@
|
||||
|
||||
|
||||
import { HTMLAttributes, useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { cn, toSearchParams } from '@/lib/utils'
|
||||
import { getFormSchema, type LoginFormValues } from './schema'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -47,17 +47,6 @@ import { useTheme } from '@/context/theme-context'
|
||||
|
||||
type UserAuthFormProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
const getFormSchema = (t: (key: string, options?: Record<string, any>) => string) =>
|
||||
z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(1, { message: t('validation.pleaseEnterUsernameOrEmail') }),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, { message: t('validation.pleaseEnterPassword') })
|
||||
.min(4, { message: t('validation.passwordMinLength', { min: 4 }) }),
|
||||
});
|
||||
|
||||
export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { setTheme } = useTheme();
|
||||
@@ -68,7 +57,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
const redirect = toSearchParams(search).get('redirect') || '/';
|
||||
|
||||
const formSchema = getFormSchema(t)
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
const form = useForm<LoginFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
@@ -81,7 +70,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
async function onSubmit(data: z.infer<typeof formSchema>) {
|
||||
async function onSubmit(data: LoginFormValues) {
|
||||
setIsLoading(true)
|
||||
|
||||
mutation.mutate(data, {
|
||||
|
||||
189
web/src/features/oauth2/components/__tests__/schema.test.ts
Normal file
189
web/src/features/oauth2/components/__tests__/schema.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getOAuth2Schema } from '../schema'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
describe('OAuth2 Form Schema', () => {
|
||||
const schema = getOAuth2Schema(t)
|
||||
|
||||
const validData = {
|
||||
client_id: 'my-client-id',
|
||||
auth_url: 'https://accounts.example.com/o/oauth2/auth',
|
||||
token_url: 'https://oauth2.example.com/token',
|
||||
redirect_uri: 'https://myapp.example.com/oauth2/callback',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
describe('client_id field', () => {
|
||||
it('rejects empty client_id', () => {
|
||||
const result = schema.safeParse({ ...validData, client_id: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid client_id', () => {
|
||||
const result = schema.safeParse(validData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('client_secret field', () => {
|
||||
it('accepts undefined client_secret', () => {
|
||||
const result = schema.safeParse(validData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts provided client_secret', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
client_secret: 'my-secret',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('auth_url field', () => {
|
||||
it('rejects empty auth_url', () => {
|
||||
const result = schema.safeParse({ ...validData, auth_url: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid URL format for auth_url', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
auth_url: 'not-a-url',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid auth_url', () => {
|
||||
const result = schema.safeParse(validData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('token_url field', () => {
|
||||
it('rejects empty token_url', () => {
|
||||
const result = schema.safeParse({ ...validData, token_url: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid URL format for token_url', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
token_url: 'not-a-url',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('redirect_uri field', () => {
|
||||
it('rejects empty redirect_uri', () => {
|
||||
const result = schema.safeParse({ ...validData, redirect_uri: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid URL format for redirect_uri', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
redirect_uri: 'not-a-url',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scopes field', () => {
|
||||
it('accepts empty scopes array', () => {
|
||||
const result = schema.safeParse({ ...validData, scopes: [] })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts valid scopes', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
scopes: [{ value: 'https://mail.google.com/' }],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects scope with empty value', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
scopes: [{ value: '' }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extra_params field', () => {
|
||||
it('accepts empty extra_params array', () => {
|
||||
const result = schema.safeParse({ ...validData, extra_params: [] })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts valid extra_params', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
extra_params: [{ key: 'access_type', value: 'offline' }],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects param with empty key', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
extra_params: [{ key: '', value: 'offline' }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects param with empty value', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
extra_params: [{ key: 'access_type', value: '' }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('enabled field', () => {
|
||||
it('accepts enabled: true', () => {
|
||||
const result = schema.safeParse(validData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts enabled: false', () => {
|
||||
const result = schema.safeParse({ ...validData, enabled: false })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('description field', () => {
|
||||
it('rejects description longer than 255 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
description: 'a'.repeat(256),
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts description of exactly 255 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
description: 'a'.repeat(255),
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('use_proxy field', () => {
|
||||
it('accepts undefined use_proxy', () => {
|
||||
const result = schema.safeParse(validData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts numeric use_proxy', () => {
|
||||
const result = schema.safeParse({ ...validData, use_proxy: 1 })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,6 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { z } from 'zod'
|
||||
import { useFieldArray, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
@@ -53,115 +52,22 @@ import { AxiosError } from 'axios'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import useProxyList from '@/hooks/use-proxy'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const getParamSchema = (t: (key: string) => string) => z.object({
|
||||
key: z.string({ required_error: t('oauth2.keyIsRequired') }).min(1, t('oauth2.keyCannotBeEmpty')),
|
||||
value: z.string({ required_error: t('oauth2.valueIsRequired') }).min(1, t('oauth2.valueCannotBeEmpty')),
|
||||
});
|
||||
|
||||
const paramSchema = z.object({
|
||||
key: z.string({ required_error: 'Key is required' }).min(1, "Key cannot be empty"),
|
||||
value: z.string({ required_error: 'Value is required' }).min(1, "Value cannot be empty"),
|
||||
});
|
||||
|
||||
const getScopeSchema = (t: (key: string) => string) => z.object({
|
||||
value: z.string({ required_error: t('oauth2.valueIsRequired') }).min(1, t('oauth2.valueCannotBeEmpty')),
|
||||
});
|
||||
|
||||
const scopeSchema = z.object({
|
||||
value: z.string({ required_error: 'Value is required' }).min(1, "Value cannot be empty"),
|
||||
});
|
||||
|
||||
const extraparamSchema = z.record(z.string()).optional();
|
||||
const authorizescopeSchema = z.array(z.string()).optional();
|
||||
|
||||
import { getOAuth2Schema, type OAuth2FormValues } from './schema'
|
||||
|
||||
function convertToExtraParamsSchema(
|
||||
record: z.infer<typeof extraparamSchema>
|
||||
): z.infer<typeof paramSchema>[] {
|
||||
if (!record) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(record).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
record: Record<string, string> | undefined
|
||||
): { key: string; value: string }[] {
|
||||
if (!record) return []
|
||||
return Object.entries(record).map(([key, value]) => ({ key, value }))
|
||||
}
|
||||
|
||||
|
||||
function convertToScopeSchema(authorizeScopes: z.infer<typeof authorizescopeSchema>): z.infer<typeof scopeSchema>[] {
|
||||
if (!authorizeScopes || authorizeScopes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return authorizeScopes.map((scope) => ({
|
||||
value: scope,
|
||||
}));
|
||||
function convertToScopeSchema(
|
||||
scopes: string[] | undefined
|
||||
): { value: string }[] {
|
||||
if (!scopes || scopes.length === 0) return []
|
||||
return scopes.map((scope) => ({ value: scope }))
|
||||
}
|
||||
|
||||
const getOAuth2Schema = (t: (key: string) => string) => z.object({
|
||||
description: z.string().max(255, { message: t('oauth2.descriptionMustNotExceed255Characters') }).optional(),
|
||||
client_id: z.string({
|
||||
required_error: t('oauth2.clientIdIsRequired'),
|
||||
}).min(1, { message: t('oauth2.clientIdCannotBeEmpty') }),
|
||||
client_secret: z.string().optional(),
|
||||
auth_url: z.string({
|
||||
required_error: t('oauth2.authorizationUrlIsRequired'),
|
||||
})
|
||||
.min(1, { message: t('oauth2.authorizationUrlCannotBeEmpty') })
|
||||
.url({ message: t('oauth2.invalidAuthorizationUrlFormat') }),
|
||||
|
||||
token_url: z.string({
|
||||
required_error: t('oauth2.tokenUrlIsRequired'),
|
||||
})
|
||||
.min(1, { message: t('oauth2.tokenUrlCannotBeEmpty') })
|
||||
.url({ message: t('oauth2.invalidTokenUrlFormat') }),
|
||||
|
||||
redirect_uri: z.string({
|
||||
required_error: t('oauth2.redirectUriIsRequired'),
|
||||
})
|
||||
.min(1, { message: t('oauth2.redirectUriCannotBeEmpty') })
|
||||
.url({ message: t('oauth2.invalidRedirectUriFormat') }),
|
||||
|
||||
scopes: z.array(getScopeSchema(t)).optional(),
|
||||
extra_params: z.array(getParamSchema(t)).optional(),
|
||||
enabled: z.boolean(),
|
||||
use_proxy: z.number().optional(),
|
||||
});
|
||||
|
||||
const oauth2Schema = z.object({
|
||||
description: z.string().max(255, { message: "Description must not exceed 255 characters." }).optional(),
|
||||
client_id: z.string({
|
||||
required_error: "Client ID is required",
|
||||
}).min(1, { message: "Client ID cannot be empty" }),
|
||||
client_secret: z.string().optional(),
|
||||
auth_url: z.string({
|
||||
required_error: "Authorization URL is required",
|
||||
})
|
||||
.min(1, { message: "Authorization URL cannot be empty" })
|
||||
.url({ message: "Invalid Authorization URL format" }),
|
||||
|
||||
token_url: z.string({
|
||||
required_error: "Token URL is required",
|
||||
})
|
||||
.min(1, { message: "Token URL cannot be empty" })
|
||||
.url({ message: "Invalid Token URL format" }),
|
||||
|
||||
redirect_uri: z.string({
|
||||
required_error: "Redirect URI is required",
|
||||
})
|
||||
.min(1, { message: "Redirect URI cannot be empty" })
|
||||
.url({ message: "Invalid Redirect URI format" }),
|
||||
|
||||
scopes: z.array(scopeSchema).optional(),
|
||||
extra_params: z.array(paramSchema).optional(),
|
||||
enabled: z.boolean(),
|
||||
use_proxy: z.number().optional(),
|
||||
});
|
||||
|
||||
export type OAuth2Form = z.infer<typeof oauth2Schema>;
|
||||
|
||||
|
||||
interface Props {
|
||||
currentRow?: OAuth2Entity
|
||||
open: boolean
|
||||
@@ -185,7 +91,7 @@ const defaultValues = {
|
||||
export function ActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const isEdit = !!currentRow
|
||||
const form = useForm<OAuth2Form>({
|
||||
const form = useForm<OAuth2FormValues>({
|
||||
resolver: zodResolver(getOAuth2Schema(t)),
|
||||
defaultValues: isEdit
|
||||
? {
|
||||
@@ -255,7 +161,7 @@ export function ActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const onSubmit = (values: OAuth2Form) => {
|
||||
const onSubmit = (values: OAuth2FormValues) => {
|
||||
if (!isEdit) {
|
||||
if (!values.client_secret) {
|
||||
form.setError('client_secret', {
|
||||
|
||||
56
web/src/features/oauth2/components/schema.ts
Normal file
56
web/src/features/oauth2/components/schema.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const paramEntry = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
key: z
|
||||
.string({ required_error: t('oauth2.keyIsRequired') })
|
||||
.min(1, t('oauth2.keyCannotBeEmpty')),
|
||||
value: z
|
||||
.string({ required_error: t('oauth2.valueIsRequired') })
|
||||
.min(1, t('oauth2.valueCannotBeEmpty')),
|
||||
})
|
||||
|
||||
const scopeEntry = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
value: z
|
||||
.string({ required_error: t('oauth2.valueIsRequired') })
|
||||
.min(1, t('oauth2.valueCannotBeEmpty')),
|
||||
})
|
||||
|
||||
export const getOAuth2Schema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
description: z
|
||||
.string()
|
||||
.max(255, { message: t('oauth2.descriptionMustNotExceed255Characters') })
|
||||
.optional(),
|
||||
client_id: z
|
||||
.string({
|
||||
required_error: t('oauth2.clientIdIsRequired'),
|
||||
})
|
||||
.min(1, { message: t('oauth2.clientIdCannotBeEmpty') }),
|
||||
client_secret: z.string().optional(),
|
||||
auth_url: z
|
||||
.string({
|
||||
required_error: t('oauth2.authorizationUrlIsRequired'),
|
||||
})
|
||||
.min(1, { message: t('oauth2.authorizationUrlCannotBeEmpty') })
|
||||
.url({ message: t('oauth2.invalidAuthorizationUrlFormat') }),
|
||||
token_url: z
|
||||
.string({
|
||||
required_error: t('oauth2.tokenUrlIsRequired'),
|
||||
})
|
||||
.min(1, { message: t('oauth2.tokenUrlCannotBeEmpty') })
|
||||
.url({ message: t('oauth2.invalidTokenUrlFormat') }),
|
||||
redirect_uri: z
|
||||
.string({
|
||||
required_error: t('oauth2.redirectUriIsRequired'),
|
||||
})
|
||||
.min(1, { message: t('oauth2.redirectUriCannotBeEmpty') })
|
||||
.url({ message: t('oauth2.invalidRedirectUriFormat') }),
|
||||
scopes: z.array(scopeEntry(t)).optional(),
|
||||
extra_params: z.array(paramEntry(t)).optional(),
|
||||
enabled: z.boolean(),
|
||||
use_proxy: z.number().optional(),
|
||||
})
|
||||
|
||||
export type OAuth2FormValues = z.infer<ReturnType<typeof getOAuth2Schema>>
|
||||
153
web/src/features/settings/profile/__tests__/schema.test.ts
Normal file
153
web/src/features/settings/profile/__tests__/schema.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { profileSchema } from '../schema'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
describe('Profile Form Schema', () => {
|
||||
const schema = profileSchema(t)
|
||||
|
||||
describe('username field', () => {
|
||||
it('rejects empty username', () => {
|
||||
const result = schema.safeParse({
|
||||
username: '',
|
||||
email: 'user@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues.filter(
|
||||
(i) => i.path[0] === 'username'
|
||||
)
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects username shorter than 3 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'ab',
|
||||
email: 'user@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts username of exactly 3 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'abc',
|
||||
email: 'user@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects username longer than 32 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'a'.repeat(33),
|
||||
email: 'user@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts username of exactly 32 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'a'.repeat(32),
|
||||
email: 'user@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('email field', () => {
|
||||
it('rejects empty email', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid email format', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'not-an-email',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects email without domain', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'user@',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid email', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'user@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('password field', () => {
|
||||
it('accepts empty password (keep current)', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'user@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
// Empty password should be transformed to undefined
|
||||
expect(result.data.password).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects password shorter than 8 characters when provided', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'user@example.com',
|
||||
password: 'short',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts password of exactly 8 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'user@example.com',
|
||||
password: '12345678',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects password longer than 256 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'user@example.com',
|
||||
password: 'a'.repeat(257),
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('transforms non-empty password to the string value', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'user@example.com',
|
||||
password: 'myNewPassword123',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.password).toBe('myNewPassword123')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
@@ -42,41 +41,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { FileWithPreview } from '@/hooks/use-file-upload'
|
||||
import AvatarUpload from './avatar-upload'
|
||||
import { PermissionsDialog } from '../access/permissions-dialog'
|
||||
|
||||
const profileSchema = (t: (key: string) => string) => z.object({
|
||||
username: z
|
||||
.string({
|
||||
required_error: t('settings.profile.validation.username.required'),
|
||||
})
|
||||
.min(3, {
|
||||
message: t('settings.profile.validation.username.min'),
|
||||
})
|
||||
.max(32, {
|
||||
message: t('settings.profile.validation.username.max'),
|
||||
}),
|
||||
|
||||
email: z
|
||||
.string({
|
||||
required_error: t('settings.profile.validation.email.required'),
|
||||
})
|
||||
.email({
|
||||
message: t('settings.profile.validation.email.invalid'),
|
||||
}),
|
||||
|
||||
password: z
|
||||
.string()
|
||||
.min(8, {
|
||||
message: t('settings.profile.validation.password.min'),
|
||||
})
|
||||
.max(256, {
|
||||
message: t('settings.profile.validation.password.max'),
|
||||
})
|
||||
.or(z.literal(''))
|
||||
.optional()
|
||||
.transform((v) => (v ? v : undefined)),
|
||||
})
|
||||
|
||||
export type ProfileFormValues = z.infer<ReturnType<typeof profileSchema>>
|
||||
import { profileSchema, type ProfileFormValues } from './schema'
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
37
web/src/features/settings/profile/schema.ts
Normal file
37
web/src/features/settings/profile/schema.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const profileSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
username: z
|
||||
.string({
|
||||
required_error: t('settings.profile.validation.username.required'),
|
||||
})
|
||||
.min(3, {
|
||||
message: t('settings.profile.validation.username.min'),
|
||||
})
|
||||
.max(32, {
|
||||
message: t('settings.profile.validation.username.max'),
|
||||
}),
|
||||
|
||||
email: z
|
||||
.string({
|
||||
required_error: t('settings.profile.validation.email.required'),
|
||||
})
|
||||
.email({
|
||||
message: t('settings.profile.validation.email.invalid'),
|
||||
}),
|
||||
|
||||
password: z
|
||||
.string()
|
||||
.min(8, {
|
||||
message: t('settings.profile.validation.password.min'),
|
||||
})
|
||||
.max(256, {
|
||||
message: t('settings.profile.validation.password.max'),
|
||||
})
|
||||
.or(z.literal(''))
|
||||
.optional()
|
||||
.transform((v) => (v ? v : undefined)),
|
||||
})
|
||||
|
||||
export type ProfileFormValues = z.infer<ReturnType<typeof profileSchema>>
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { proxyFormSchema } from '../schema'
|
||||
|
||||
describe('Proxy Form Schema', () => {
|
||||
describe('url field - basic validation', () => {
|
||||
it('rejects empty URL', () => {
|
||||
const result = proxyFormSchema.safeParse({ url: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid socks5 URL', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts valid http URL', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'http://proxy.example.com:8080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('url field - protocol validation', () => {
|
||||
it('rejects https protocol', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'https://proxy.example.com:443',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message?.includes('http:// or socks5://')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects ftp protocol', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'ftp://files.example.com',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects URL without protocol', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: '127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message?.includes('Invalid URL format')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('url field - port validation', () => {
|
||||
it('rejects port 0', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:0',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects port > 65535', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:99999',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts port 65535', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:65535',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts port 1', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:1',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to port 1080 when no port specified', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('url field - hostname validation', () => {
|
||||
it('accepts IP address hostname', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://192.168.1.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts domain hostname', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://proxy.internal:1080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects hostname with invalid characters', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://proxy_host:1080',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message?.includes('Hostname contains invalid characters')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('url field - auth validation', () => {
|
||||
it('rejects username without password', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://user@127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message?.includes('Password cannot be empty')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects short password when username provided', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://user:short@127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message?.includes('Password must be at least 8')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts valid auth credentials', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://user:password123@127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts URL without auth (no credentials)', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,6 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
@@ -47,75 +46,7 @@ import { Loader2 } from 'lucide-react'
|
||||
import { add_proxy, update_proxy } from '@/api/system/api'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Proxy } from '@/api/system/api'
|
||||
|
||||
const proxyFormSchema = z.object({
|
||||
url: z.string()
|
||||
.min(1, "Proxy address cannot be empty")
|
||||
.superRefine((value, ctx) => {
|
||||
|
||||
if (value.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch (e) {
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Invalid URL format",
|
||||
path: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (url.protocol !== 'socks5:' && url.protocol !== 'http:') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "URL must start with http:// or socks5://",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (!/^[a-zA-Z0-9\-\.]+$/.test(url.hostname)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Hostname contains invalid characters",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const port = parseInt(url.port || '1080');
|
||||
if (port <= 0 || port > 65535) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Port must be between 1-65535",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (url.username && !url.password) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Password cannot be empty when username is provided",
|
||||
path: [],
|
||||
});
|
||||
} else if (url.password && url.password.length < 8) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Password must be at least 8 characters",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
export type ProxyForm = z.infer<typeof proxyFormSchema>;
|
||||
import { proxyFormSchema, type ProxyFormValues } from './schema'
|
||||
|
||||
|
||||
interface Props {
|
||||
@@ -140,7 +71,7 @@ export function ProxyActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const isEdit = !!currentRow
|
||||
const queryClient = useQueryClient();
|
||||
const form = useForm<ProxyForm>({
|
||||
const form = useForm<ProxyFormValues>({
|
||||
resolver: zodResolver(proxyFormSchema),
|
||||
defaultValues: isEdit
|
||||
? mapCurrentRowToFormValues(currentRow)
|
||||
@@ -186,7 +117,7 @@ export function ProxyActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
}
|
||||
|
||||
|
||||
const onSubmit = (values: ProxyForm) => {
|
||||
const onSubmit = (values: ProxyFormValues) => {
|
||||
const url = values.url;
|
||||
if (isEdit) {
|
||||
updateMutation.mutate(url);
|
||||
|
||||
65
web/src/features/settings/proxy/components/schema.ts
Normal file
65
web/src/features/settings/proxy/components/schema.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const proxyFormSchema = z.object({
|
||||
url: z
|
||||
.string()
|
||||
.min(1, 'Proxy address cannot be empty')
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(value)
|
||||
} catch (_e) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid URL format',
|
||||
path: [],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (url.protocol !== 'socks5:' && url.protocol !== 'http:') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'URL must start with http:// or socks5://',
|
||||
path: [],
|
||||
})
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9\-\.]+$/.test(url.hostname)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Hostname contains invalid characters',
|
||||
path: [],
|
||||
})
|
||||
}
|
||||
|
||||
const port = parseInt(url.port || '1080')
|
||||
if (port <= 0 || port > 65535) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Port must be between 1-65535',
|
||||
path: [],
|
||||
})
|
||||
}
|
||||
|
||||
if (url.username && !url.password) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Password cannot be empty when username is provided',
|
||||
path: [],
|
||||
})
|
||||
} else if (url.password && url.password.length < 8) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Password must be at least 8 characters',
|
||||
path: [],
|
||||
})
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export type ProxyFormValues = z.infer<typeof proxyFormSchema>
|
||||
129
web/src/features/users/roles/components/__tests__/schema.test.ts
Normal file
129
web/src/features/users/roles/components/__tests__/schema.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getRoleFormSchema } from '../schema'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
describe('Role Form Schema', () => {
|
||||
const schema = getRoleFormSchema(t)
|
||||
|
||||
describe('name field', () => {
|
||||
it('rejects empty name', () => {
|
||||
const result = schema.safeParse({
|
||||
name: '',
|
||||
role_type: 'Account',
|
||||
permissions: ['data:read'],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid name', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Viewer',
|
||||
role_type: 'Account',
|
||||
permissions: ['data:read'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('role_type field', () => {
|
||||
it('accepts Global role type', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Admin',
|
||||
role_type: 'Global',
|
||||
permissions: ['system:access'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts Account role type', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Viewer',
|
||||
role_type: 'Account',
|
||||
permissions: ['data:read'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid role type', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Test',
|
||||
role_type: 'Invalid',
|
||||
permissions: ['data:read'],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('permissions field', () => {
|
||||
it('rejects empty permissions array', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Viewer',
|
||||
role_type: 'Account',
|
||||
permissions: [],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts single permission', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Viewer',
|
||||
role_type: 'Account',
|
||||
permissions: ['data:read'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts multiple permissions', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Manager',
|
||||
role_type: 'Account',
|
||||
permissions: ['data:read', 'data:manage', 'account:manage'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts all available permissions', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Super Admin',
|
||||
role_type: 'Global',
|
||||
permissions: [
|
||||
'system:access',
|
||||
'system:root',
|
||||
'user:manage',
|
||||
'user:view',
|
||||
'token:manage',
|
||||
'account:create',
|
||||
'account:manage:all',
|
||||
'data:read:all',
|
||||
'data:manage:all',
|
||||
'data:raw:download:all',
|
||||
'data:delete:all',
|
||||
'data:export:batch:all',
|
||||
],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('description field', () => {
|
||||
it('accepts undefined description', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Viewer',
|
||||
role_type: 'Account',
|
||||
permissions: ['data:read'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts description string', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Viewer',
|
||||
role_type: 'Account',
|
||||
permissions: ['data:read'],
|
||||
description: 'Read-only access to data',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
@@ -49,6 +48,7 @@ import {
|
||||
} from '@/components/ui/radio-group'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getRoleFormSchema, type RoleFormValues } from './schema'
|
||||
|
||||
interface Props {
|
||||
currentRow?: UserRole
|
||||
@@ -97,16 +97,9 @@ export function RoleActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const roleFormSchema = z.object({
|
||||
name: z.string().min(1, t('roles.validation.name_required')),
|
||||
role_type: z.enum(['Global', 'Account']),
|
||||
permissions: z.array(z.string()).min(1, t('roles.validation.perm_required')),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
const roleFormSchema = getRoleFormSchema(t)
|
||||
|
||||
type RoleForm = z.infer<typeof roleFormSchema>
|
||||
|
||||
const form = useForm<RoleForm>({
|
||||
const form = useForm<RoleFormValues>({
|
||||
resolver: zodResolver(roleFormSchema),
|
||||
defaultValues: {
|
||||
name: isEdit ? currentRow.name : '',
|
||||
@@ -117,7 +110,7 @@ export function RoleActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: RoleForm) =>
|
||||
mutationFn: (values: RoleFormValues) =>
|
||||
isEdit ? update_role(currentRow!.id, values) : create_role(values),
|
||||
onSuccess: () => {
|
||||
toast({ title: t(isEdit ? 'roles.actions.success_update' : 'roles.actions.success_create') })
|
||||
|
||||
11
web/src/features/users/roles/components/schema.ts
Normal file
11
web/src/features/users/roles/components/schema.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const getRoleFormSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
name: z.string().min(1, t('roles.validation.name_required')),
|
||||
role_type: z.enum(['Global', 'Account']),
|
||||
permissions: z.array(z.string()).min(1, t('roles.validation.perm_required')),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
export type RoleFormValues = z.infer<ReturnType<typeof getRoleFormSchema>>
|
||||
308
web/src/features/users/users/components/__tests__/schema.test.ts
Normal file
308
web/src/features/users/users/components/__tests__/schema.test.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getCreateUserSchema, getUpdateUserSchema } from '../schema'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
const validBaseUser = {
|
||||
username: 'johndoe',
|
||||
email: 'john@example.com',
|
||||
global_roles: [1],
|
||||
}
|
||||
|
||||
const validCreateUser = {
|
||||
...validBaseUser,
|
||||
password: 'securePassword123',
|
||||
}
|
||||
|
||||
const invalidCases = [
|
||||
{ desc: 'empty username', data: { ...validCreateUser, username: '' } },
|
||||
{
|
||||
desc: 'username shorter than 3',
|
||||
data: { ...validCreateUser, username: 'ab' },
|
||||
},
|
||||
{
|
||||
desc: 'username longer than 32',
|
||||
data: { ...validCreateUser, username: 'a'.repeat(33) },
|
||||
},
|
||||
{ desc: 'empty email', data: { ...validCreateUser, email: '' } },
|
||||
{
|
||||
desc: 'invalid email format',
|
||||
data: { ...validCreateUser, email: 'not-an-email' },
|
||||
},
|
||||
{
|
||||
desc: 'empty global_roles',
|
||||
data: { ...validCreateUser, global_roles: [] },
|
||||
},
|
||||
{
|
||||
desc: 'empty password on create',
|
||||
data: { ...validBaseUser, password: '' },
|
||||
},
|
||||
{
|
||||
desc: 'short password on create',
|
||||
data: { ...validBaseUser, password: 'short' },
|
||||
},
|
||||
{
|
||||
desc: 'password longer than 256 on create',
|
||||
data: { ...validBaseUser, password: 'a'.repeat(257) },
|
||||
},
|
||||
]
|
||||
|
||||
describe('Create User Schema', () => {
|
||||
const schema = getCreateUserSchema(t)
|
||||
|
||||
it('accepts valid user data', () => {
|
||||
const result = schema.safeParse(validCreateUser)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it.each(invalidCases)('rejects $desc', ({ data }) => {
|
||||
const result = schema.safeParse(data)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts username of exactly 3 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
username: 'abc',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts username of exactly 32 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
username: 'a'.repeat(32),
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts password of exactly 8 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
...validBaseUser,
|
||||
password: '12345678',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts password of exactly 256 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
...validBaseUser,
|
||||
password: 'a'.repeat(256),
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Update User Schema', () => {
|
||||
const schema = getUpdateUserSchema(t)
|
||||
|
||||
it('accepts empty password (keep current)', () => {
|
||||
const result = schema.safeParse({
|
||||
...validBaseUser,
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.password).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts undefined password', () => {
|
||||
const result = schema.safeParse(validBaseUser)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.password).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects short password when provided', () => {
|
||||
const result = schema.safeParse({
|
||||
...validBaseUser,
|
||||
password: 'short',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid password when provided', () => {
|
||||
const result = schema.safeParse({
|
||||
...validBaseUser,
|
||||
password: 'newPassword123',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.password).toBe('newPassword123')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Schema - ACL', () => {
|
||||
const schema = getCreateUserSchema(t)
|
||||
|
||||
describe('ip_whitelist validation', () => {
|
||||
it('accepts valid IPv4 addresses', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: { ip_whitelist: '192.168.1.1\n10.0.0.1' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts valid IPv6 address', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: { ip_whitelist: '2001:0db8:85a3:0000:0000:8a2e:0370:7334' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid IP format', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: { ip_whitelist: 'not-an-ip' },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid IP with too many octets', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: { ip_whitelist: '192.168.1.1.1' },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects IP with octet > 255', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: { ip_whitelist: '300.1.1.1' },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts empty ACL (no security policies)', () => {
|
||||
const result = schema.safeParse(validCreateUser)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rate_limit validation', () => {
|
||||
it('accepts valid rate_limit', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: {
|
||||
rate_limit: { quota: 100, interval: 60 },
|
||||
},
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('transforms ACL with only rate_limit', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: {
|
||||
rate_limit: { quota: 100, interval: 60 },
|
||||
},
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success && result.data.acl) {
|
||||
expect(result.data.acl.rate_limit).toBeDefined()
|
||||
expect(result.data.acl.rate_limit!.quota).toBe(100)
|
||||
expect(result.data.acl.ip_whitelist).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns undefined for ACL with only empty ip_whitelist', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: { ip_whitelist: '' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.acl).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns undefined for ACL with no data', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: { ip_whitelist: '\n\n' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.acl).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Schema - account_access_entries', () => {
|
||||
const schema = getCreateUserSchema(t)
|
||||
|
||||
it('accepts empty account_access_entries (defaults to [])', () => {
|
||||
const result = schema.safeParse(validCreateUser)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.account_access_entries).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts valid account access entries', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
account_access_entries: [
|
||||
{ accountId: 1, roleId: 2 },
|
||||
{ accountId: 3, roleId: 4 },
|
||||
],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects entry with accountId 0', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
account_access_entries: [{ accountId: 0, roleId: 1 }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects entry with roleId 0', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
account_access_entries: [{ accountId: 1, roleId: 0 }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Schema - description', () => {
|
||||
const schema = getCreateUserSchema(t)
|
||||
|
||||
it('accepts undefined description', () => {
|
||||
const result = schema.safeParse(validCreateUser)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts empty string description', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
description: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts valid description', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
description: 'A test user account',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects description longer than 256 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
description: 'a'.repeat(257),
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { z } from 'zod'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useFieldArray, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
@@ -56,75 +55,9 @@ import { useRoles } from '@/hooks/use-roles'
|
||||
import { PasswordInput } from '@/components/password-input'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getCreateUserSchema, getUpdateUserSchema, type UserFormValues } from './schema'
|
||||
|
||||
const isValidIP = (ip: string) => {
|
||||
const ipv4 = /^(?:(?:\d{1,3}\.){3}\d{1,3})$/
|
||||
const ipv6 = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/
|
||||
return ipv4.test(ip) || ipv6.test(ip)
|
||||
}
|
||||
|
||||
const accountAccessEntry = (t: any) => z.object({
|
||||
accountId: z.number().min(1, t('users.actions.schema.account_required')),
|
||||
roleId: z.number().min(1, t('users.actions.schema.role_required'))
|
||||
});
|
||||
|
||||
const baseUserSchema = (t: any) => ({
|
||||
username: z.string()
|
||||
.min(1, t('users.actions.schema.username_required'))
|
||||
.min(3, t('users.actions.schema.username_min'))
|
||||
.max(32, t('users.actions.schema.username_max')),
|
||||
email: z.string()
|
||||
.min(1, t('users.actions.schema.email_required'))
|
||||
.email(t('users.actions.schema.email_invalid')),
|
||||
global_roles: z.array(z.number()).min(1, t('users.actions.schema.global_role_required')),
|
||||
account_access_entries: z.array(accountAccessEntry(t)).optional().default([]),
|
||||
description: z.string().max(256, t('users.actions.schema.description_max')).optional().or(z.literal('')),
|
||||
acl: z.object({
|
||||
ip_whitelist: z.string().optional(),
|
||||
rate_limit: z.object({
|
||||
quota: z.number().positive().optional(),
|
||||
interval: z.number().positive().optional(),
|
||||
}).optional()
|
||||
}).optional().transform((data) => {
|
||||
if (!data) return undefined;
|
||||
const ips = data.ip_whitelist?.split('\n').map(v => v.trim()).filter(Boolean) || [];
|
||||
const finalRateLimit = (data.rate_limit?.quota && data.rate_limit?.interval)
|
||||
? data.rate_limit
|
||||
: undefined;
|
||||
if (ips.length === 0 && !finalRateLimit) return undefined;
|
||||
return {
|
||||
ip_whitelist: ips.length > 0 ? ips.join('\n') : undefined,
|
||||
rate_limit: finalRateLimit
|
||||
};
|
||||
})
|
||||
.refine((data) => {
|
||||
if (!data?.ip_whitelist) return true;
|
||||
return data.ip_whitelist.split('\n').every(isValidIP);
|
||||
}, {
|
||||
message: t('users.actions.schema.ip_invalid'),
|
||||
path: ["ip_whitelist"]
|
||||
})
|
||||
});
|
||||
|
||||
const createUserSchema = (t: any) => z.object({
|
||||
...baseUserSchema(t),
|
||||
password: z.string()
|
||||
.min(1, t('users.actions.schema.password_required'))
|
||||
.min(8, t('users.actions.schema.password_min'))
|
||||
.max(256, t('users.actions.schema.password_max')),
|
||||
});
|
||||
|
||||
const updateUserSchema = (t: any) => z.object({
|
||||
...baseUserSchema(t),
|
||||
password: z.string()
|
||||
.min(8, t('users.actions.schema.password_min'))
|
||||
.max(256, t('users.actions.schema.password_max'))
|
||||
.or(z.literal(''))
|
||||
.optional()
|
||||
.transform(v => v || undefined),
|
||||
});
|
||||
|
||||
export type UserForm = z.infer<ReturnType<typeof createUserSchema>> | z.infer<ReturnType<typeof updateUserSchema>>
|
||||
export type UserForm = UserFormValues
|
||||
|
||||
interface Props {
|
||||
currentRow?: User
|
||||
@@ -142,7 +75,7 @@ export function UserActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { minimalList: allAccounts } = useMinimalAccountList()
|
||||
|
||||
const form = useForm<UserForm>({
|
||||
resolver: zodResolver(isEdit ? updateUserSchema(t) : createUserSchema(t)),
|
||||
resolver: zodResolver(isEdit ? getUpdateUserSchema(t) : getCreateUserSchema(t)),
|
||||
defaultValues: useMemo(() => {
|
||||
if (isEdit && currentRow) {
|
||||
const accessEntries = currentRow.account_access_map
|
||||
|
||||
106
web/src/features/users/users/components/schema.ts
Normal file
106
web/src/features/users/users/components/schema.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const accountAccessEntry = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
accountId: z.number().min(1, t('users.actions.schema.account_required')),
|
||||
roleId: z.number().min(1, t('users.actions.schema.role_required')),
|
||||
})
|
||||
|
||||
const isValidIPv4 = (ip: string): boolean => {
|
||||
const parts = ip.split('.')
|
||||
if (parts.length !== 4) return false
|
||||
return parts.every((part) => {
|
||||
const num = Number(part)
|
||||
return part === String(num) && num >= 0 && num <= 255
|
||||
})
|
||||
}
|
||||
|
||||
const isValidIP = (ip: string) => {
|
||||
const ipv6 = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/
|
||||
return isValidIPv4(ip) || ipv6.test(ip)
|
||||
}
|
||||
|
||||
export const getBaseUserSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(1, t('users.actions.schema.username_required'))
|
||||
.min(3, t('users.actions.schema.username_min'))
|
||||
.max(32, t('users.actions.schema.username_max')),
|
||||
email: z
|
||||
.string()
|
||||
.min(1, t('users.actions.schema.email_required'))
|
||||
.email(t('users.actions.schema.email_invalid')),
|
||||
global_roles: z
|
||||
.array(z.number())
|
||||
.min(1, t('users.actions.schema.global_role_required')),
|
||||
account_access_entries: z
|
||||
.array(accountAccessEntry(t))
|
||||
.optional()
|
||||
.default([]),
|
||||
description: z
|
||||
.string()
|
||||
.max(256, t('users.actions.schema.description_max'))
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
acl: z
|
||||
.object({
|
||||
ip_whitelist: z.string().optional(),
|
||||
rate_limit: z
|
||||
.object({
|
||||
quota: z.number().positive().optional(),
|
||||
interval: z.number().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional()
|
||||
.transform((data) => {
|
||||
if (!data) return undefined
|
||||
const ips =
|
||||
data.ip_whitelist
|
||||
?.split('\n')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean) || []
|
||||
const finalRateLimit =
|
||||
data.rate_limit?.quota && data.rate_limit?.interval
|
||||
? data.rate_limit
|
||||
: undefined
|
||||
if (ips.length === 0 && !finalRateLimit) return undefined
|
||||
return {
|
||||
ip_whitelist: ips.length > 0 ? ips.join('\n') : undefined,
|
||||
rate_limit: finalRateLimit,
|
||||
}
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data?.ip_whitelist) return true
|
||||
return data.ip_whitelist.split('\n').every(isValidIP)
|
||||
},
|
||||
{
|
||||
message: t('users.actions.schema.ip_invalid'),
|
||||
path: ['ip_whitelist'],
|
||||
}
|
||||
),
|
||||
})
|
||||
|
||||
export const getCreateUserSchema = (t: (key: string) => string) =>
|
||||
getBaseUserSchema(t).extend({
|
||||
password: z
|
||||
.string()
|
||||
.min(1, t('users.actions.schema.password_required'))
|
||||
.min(8, t('users.actions.schema.password_min'))
|
||||
.max(256, t('users.actions.schema.password_max')),
|
||||
})
|
||||
|
||||
export const getUpdateUserSchema = (t: (key: string) => string) =>
|
||||
getBaseUserSchema(t).extend({
|
||||
password: z
|
||||
.string()
|
||||
.min(8, t('users.actions.schema.password_min'))
|
||||
.max(256, t('users.actions.schema.password_max'))
|
||||
.or(z.literal(''))
|
||||
.optional()
|
||||
.transform((v) => v || undefined),
|
||||
})
|
||||
|
||||
export type UserFormValues = z.infer<ReturnType<typeof getCreateUserSchema>>
|
||||
1
web/src/test/setup.ts
Normal file
1
web/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
37
web/src/test/test-utils.tsx
Normal file
37
web/src/test/test-utils.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { type ReactElement } from 'react'
|
||||
import { render, type RenderOptions } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
function createTestQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface WrapperProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function AllProviders({ children }: WrapperProps) {
|
||||
const queryClient = createTestQueryClient()
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{children}
|
||||
</I18nextProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function customRender(ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) {
|
||||
return render(ui, { wrapper: AllProviders, ...options })
|
||||
}
|
||||
|
||||
export * from '@testing-library/react'
|
||||
export { customRender as render }
|
||||
export { createTestQueryClient }
|
||||
1
web/src/vite-env.d.ts
vendored
1
web/src/vite-env.d.ts
vendored
@@ -1 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vitest" />
|
||||
|
||||
@@ -60,7 +60,7 @@ export default {
|
||||
'5': 'hsl(var(--chart-5))'
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: 'hsl(var(--sidebar-background))',
|
||||
DEFAULT: 'hsl(var(--sidebar))',
|
||||
foreground: 'hsl(var(--sidebar-foreground))',
|
||||
primary: 'hsl(var(--sidebar-primary))',
|
||||
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/// <reference types="vitest" />
|
||||
import path from 'path'
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
|
||||
|
||||
@@ -16,4 +17,10 @@ export default defineConfig({
|
||||
'@tabler/icons-react': '@tabler/icons-react/dist/esm/icons/index.mjs',
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
css: false,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user