diff --git a/crates/bot/src/addons.rs b/crates/bot/src/addons.rs index 8db92ee..a401100 100644 --- a/crates/bot/src/addons.rs +++ b/crates/bot/src/addons.rs @@ -6,7 +6,10 @@ pub async fn show(c:&Ctx<'_>,chat:i64,client:i64,mid:Option,selected:Option let items=catalog["items"].as_array().cloned().unwrap_or_default(); let mut rows=Vec::new(); let text=if let Some(id)=selected { - let pack=items.iter().find(|p|p["id"].as_i64()==Some(id)).ok_or_else(||Error::bad("пакет недоступен для текущей подписки"))?; + let Some(pack)=items.iter().find(|p|p["id"].as_i64()==Some(id)) else { + c.tg.screen(chat,mid,"Этот пакет больше недоступен для текущей подписки. Откройте актуальные пакеты.",None,Some(keyboard(vec![vec![Btn::Data("К пакетам".into(),"addons".into())],vec![Btn::Data("В меню".into(),"menu".into())]]))).await?; + return Ok(()); + }; for cur in [currency,"XTR"] { if cur=="XTR" && currency=="XTR" && !rows.is_empty(){continue;} let amount=if cur==currency {pack["amount_minor"].as_i64()} else {pack["stars_minor"].as_i64()}; diff --git a/crates/bot/src/apps.rs b/crates/bot/src/apps.rs index 310673f..961b86d 100644 --- a/crates/bot/src/apps.rs +++ b/crates/bot/src/apps.rs @@ -94,18 +94,11 @@ pub async fn show_apps( } text.push('\n'); - // Две кнопки на приложение: поставить и сразу добавить подписку. - // - // Глубокая ссылка подставляет подписку в уже установленное - // приложение. Собирается на месте: заранее её не сохранить, - // потому что она зависит от конкретного клиента. + // Telegram buttons use HTTPS; custom imports remain on the subscription page. let mut row = Vec::new(); - if let Some(store) = a.get::, _>("store_url").filter(|s| !s.is_empty()) { + if let Some(store) = a.get::, _>("store_url").filter(|s| sn_core::bot_config::web_url(s,true)) { row.push(Btn::Url(format!("⬇️ {name}"), store)); } - if let Some(dl) = a.get::, _>("deeplink").filter(|s| !s.is_empty()) { - row.push(Btn::Url("➕ Добавить".into(), dl.replace("{url}", sub_url))); - } if !row.is_empty() { rows.push(row); } @@ -122,6 +115,10 @@ pub async fn show_apps( text.push_str(&format!("{}", esc(sub_url))); } + if !apps.is_empty() && sn_core::bot_config::web_url(sub_url,true) { + rows.push(vec![Btn::Url("📖 Инструкция".into(),sub_url.to_owned())]); + } + rows.push(vec![Btn::Data("← К устройствам".into(), "apps".into())]); c.tg.screen(chat, mid, &text, c.s.image(Screen::Sub).as_deref(), Some(keyboard(rows))) @@ -150,4 +147,43 @@ mod tests { fn разметка_в_названиях_экранируется() { assert_eq!(esc("v2ray"), "v2ray<N>"); } + + #[tokio::test] + #[ignore = "Requires isolated SN_TEST_DATABASE_URL; Telegram is loopback capture only"] + async fn sn_b06_all_platforms_use_https_instructions_and_keep_catalog() { + use serde_json::json; + let url=std::env::var("SN_TEST_DATABASE_URL").expect("isolated database required"); + let pool=sqlx::postgres::PgPoolOptions::new().max_connections(1).connect(&url).await.unwrap(); + let name:String=sqlx::query_scalar("SELECT current_database()").fetch_one(&pool).await.unwrap();assert!(name.starts_with("sn_audit_")); + sqlx::query("CREATE TEMP TABLE subscription_page_apps (LIKE public.subscription_page_apps INCLUDING ALL)").execute(&pool).await.unwrap(); + for (platform,_) in PLATFORMS { + for (name,store,active) in [("Fixture ",Some("https://store.example.test/app"),true),("No store",None,true),("Unsafe store",Some("happ://open"),true),("Hidden",Some("https://hidden.example.test"),false)] { + sqlx::query("INSERT INTO subscription_page_apps(platform,name,store_url,deeplink,guide,is_active) VALUES($1,$2,$3,'happ://add/{{URL}}','Use & connect',$4)").bind(platform).bind(name).bind(store).bind(active).execute(&pool).await.unwrap(); + } + } + let before:Vec<(String,String,Option)>=sqlx::query_as("SELECT platform,name,deeplink FROM subscription_page_apps ORDER BY platform,name").fetch_all(&pool).await.unwrap(); + let (tg,server)=crate::tg::transport_tests::mock(vec![json!({"ok":true,"result":{"message_id":10}});7]).await; + let reg=sn_payments::Registry::from_env(); + let cfg=sn_core::config::Config{database_url:url,api_bind:String::new(),sub_bind:String::new(),sub_public_url:"https://sub.example.test".into(),brand_name:"QA".into(),web_root:String::new(),sub_mode:"db".into(),panel_url:"https://panel.example.test".into(),sub_service_token:None}; + let c=Ctx{tg:&tg,pool:&pool,reg:®,cfg:&cfg,s:crate::settings::View::from_pairs(&[("bot.menu_layout",json!([{"id":"buy","enabled":false}]))])}; + let sub="https://sub.example.test/s/fixture?a=1&b=2"; + for (platform,_) in PLATFORMS {show_apps(&c,42,platform,sub,None).await.unwrap();} + show_apps(&c,42,"freebsd",sub,None).await.unwrap(); + show_apps(&c,42,"ios","http://sub.example.test/s/fixture",None).await.unwrap(); + let requests=server.await.unwrap();assert_eq!(requests.len(),7); + for (i,(_,body)) in requests.iter().enumerate() { + let buttons:Vec<_>=body["reply_markup"]["inline_keyboard"].as_array().unwrap().iter().flat_map(|x|x.as_array().unwrap()).collect(); + let urls:Vec<_>=buttons.iter().filter_map(|x|x["url"].as_str()).collect(); + assert!(urls.iter().all(|x|sn_core::bot_config::web_url(x,true)),"Unsupported inline URL for request {i}: {urls:?}"); + assert!(buttons.iter().any(|x|x["callback_data"]=="apps")); + if i<5 { + assert_eq!(urls.iter().filter(|x|**x==sub).count(),1,"one HTTPS instruction action per screen"); + assert_eq!(urls.iter().filter(|x|**x=="https://store.example.test/app").count(),1); + let text=body["text"].as_str().unwrap();assert!(text.contains("https://sub.example.test/s/fixture?a=1&b=2"));assert!(text.contains("Fixture <Happ>"));assert!(!text.contains("Hidden")); + } + if i==5 {assert!(urls.is_empty());} + } + let after:Vec<(String,String,Option)>=sqlx::query_as("SELECT platform,name,deeplink FROM subscription_page_apps ORDER BY platform,name").fetch_all(&pool).await.unwrap();assert_eq!(before,after); + println!("SN-B06: seven local Telegram requests; five platforms, missing/unsafe store, hidden app, no shop, empty platform; catalog deeplinks unchanged"); + } } diff --git a/crates/bot/src/main.rs b/crates/bot/src/main.rs index 245c3b4..4fa560a 100644 --- a/crates/bot/src/main.rs +++ b/crates/bot/src/main.rs @@ -126,7 +126,37 @@ pub struct Ctx<'a> { pub s: settings::View, } -async fn handle( +// One acknowledgement owner, before settings/database/provider work. Results use messages. +async fn handle(tg:&Tg,pool:&Pool,reg:&Registry,cfg:&Config,bot:&settings::BotSettings,u:&Value)->Result<()> { + let callback=u.get("callback_query").filter(|cq|cq["message"]["chat"]["type"]=="private"); + if let Some(cq)=callback {tg.answer_callback(cq["id"].as_str().unwrap_or_default(),None).await.ok();} + match handle_inner(tg,pool,reg,cfg,bot,u).await { + Ok(())=>Ok(()), + Err(error)=>{ + tracing::warn!(%error,"Не удалось обработать обновление бота"); + let chat=callback.map(|cq|&cq["message"]["chat"]).or_else(||u.get("message").map(|m|&m["chat"])); + if let Some(chat)=chat.filter(|chat|chat["type"]=="private") { + let data=callback.and_then(|cq|cq["data"].as_str()).unwrap_or(""); + let financial=data.starts_with("c:") || data.starts_with("m:") || data.starts_with("am:") || !u["message"]["successful_payment"].is_null(); + let text=if financial { + "Не удалось завершить действие с оплатой. Её результат пока неизвестен. Не оплачивайте повторно: проверьте свой счёт позже или обратитесь в поддержку." + } else {"Не удалось выполнить действие. Кнопка могла устареть или сервис временно недоступен. Откройте актуальное меню или обратитесь в поддержку."}; + let mut rows=vec![vec![Btn::Data("В меню".into(),"menu".into())],vec![Btn::Data("Поддержка".into(),"tickets".into())]]; + if data.strip_prefix("c:").is_some_and(|id|id.parse::().is_ok()) { + rows.insert(0,vec![Btn::Data("Проверить этот счёт".into(),data.into())]); + } + tg.send(chat["id"].as_i64().unwrap_or(0),text,Some(keyboard(rows))).await?; + Ok(()) + } else {Err(error)} + } + } +} + +fn recovery_keyboard()->Value { + keyboard(vec![vec![Btn::Data("В меню".into(),"menu".into())],vec![Btn::Data("Поддержка".into(),"tickets".into())]]) +} + +async fn handle_inner( tg: &Tg, pool: &Pool, reg: &Registry, @@ -164,21 +194,15 @@ async fn handle( } if let Some(cq) = u.get("callback_query").filter(|v| !v.is_null()) { if cq["message"]["chat"]["type"] != "private" {return Ok(());} - tg.answer_callback(cq["id"].as_str().unwrap_or_default(),None).await.ok(); let chat = cq["message"]["chat"]["id"].as_i64().unwrap_or(0); let mid = cq["message"]["message_id"].as_i64().unwrap_or(0); let user = cq["from"]["id"].as_i64().unwrap_or(0); // «Я подписался» — единственная кнопка, доступная за стеной. let recheck = cq["data"].as_str() == Some("subchk"); if gate_channel(&ctx, chat, user, Some(mid)).await? { - if recheck { - tg.answer_callback(cq["id"].as_str().unwrap_or_default(), - Some("Подписка на канал не найдена")).await.ok(); - } return Ok(()); } if recheck { - tg.answer_callback(cq["id"].as_str().unwrap_or_default(), Some("Спасибо!")).await.ok(); let client_id = ensure_client(pool, &cq["from"]).await?; sqlx::query("DELETE FROM bot_await WHERE client_id=$1").bind(client_id).execute(pool).await?; return show_menu(&ctx, chat, client_id, Some(mid)).await; @@ -268,7 +292,10 @@ struct SubInfo { async fn sub_info(pool: &Pool, client_id: i64) -> Result { let r = sqlx::query( - "SELECT c.status::text AS status, c.short_id, COALESCE(s.autorenew, false) AS autorenew, + "SELECT CASE WHEN c.status IN ('disabled','limited') THEN c.status::text + WHEN s.canceled_at IS NOT NULL THEN 'disabled' + WHEN s.id IS NULL THEN 'expired' ELSE c.status::text END AS status, + c.short_id, COALESCE(s.autorenew, false) AS autorenew, s.expires_at, s.traffic_used_bytes, s.traffic_limit_bytes, t.title AS tariff FROM clients c @@ -291,6 +318,19 @@ async fn sub_info(pool: &Pool, client_id: i64) -> Result { }) } +fn subscription_active(info:&SubInfo)->bool { + info.status=="active" && info.expires.is_none_or(|until|until>chrono::Utc::now()) +} + +fn subscription_inactive_text(info:&SubInfo)->&'static str { + match info.status.as_str() { + "disabled"=>"Доступ отключён. Обратитесь в поддержку, чтобы уточнить причину.", + "limited"=>"Доступ ограничен. Проверьте лимиты подписки и устройств или обратитесь в поддержку.", + _ if info.expires.is_some()=>"Срок подписки истёк. Откройте тарифы для продления.", + _=>"У вас пока нет активной подписки. Выберите доступный тариф, в том числе бесплатный, если он предложен. Ссылка появится после активации.", + } +} + fn main_menu( brand: &str, info: &SubInfo, @@ -299,7 +339,7 @@ fn main_menu( _panel_base: Option<&str>, ) -> (String, Value) { let brand=html_escape(&st.custom("bot.brand_name",brand)); - let active = info.status == "active"; + let active = subscription_active(info); let head = if active { let until = info .expires @@ -315,7 +355,7 @@ fn main_menu( money::format_bytes(info.used), ) } else { - format!("{brand}\n\nУ вас нет активной подписки.\nВыберите тариф — займёт минуту.") + format!("{brand}\n\n{}",subscription_inactive_text(info)) }; // Объявление под меню: акция, предупреждение о работах, что угодно. @@ -402,8 +442,8 @@ async fn show_tariffs(c: &Ctx<'_>, chat: i64, client_id: i64, mid: Option) if rows.is_empty() { let text = "Тарифы пока не настроены. Загляните позже."; match mid { - Some(m) => tg.edit(chat, m, text, None).await?, - None => { tg.send(chat, text, None).await?; } + Some(m) => tg.edit(chat, m, text, Some(recovery_keyboard())).await?, + None => { tg.send(chat, text, Some(recovery_keyboard())).await?; } } return Ok(()); } @@ -488,7 +528,7 @@ async fn show_periods(c: &Ctx<'_>, chat: i64, mid: i64, tariff_id: i64) -> Resul .fetch_all(pool) .await?; - let title: String = sqlx::query_scalar("SELECT title FROM tariffs WHERE id = $1") + let title: String = sqlx::query_scalar("SELECT title FROM tariffs WHERE id = $1 AND is_active AND is_visible") .bind(tariff_id) .fetch_one(pool) .await?; @@ -557,6 +597,11 @@ async fn show_methods( tariff_id: i64, days: i32, ) -> Result<()> { + let available:bool=sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM tariffs WHERE id=$1 AND is_active AND is_visible)") + .bind(tariff_id).fetch_one(pool).await?; + if !available { + return tg.edit(chat,mid,"Этот тариф больше недоступен. Откройте актуальные тарифы.",Some(keyboard(vec![vec![Btn::Data("К тарифам".into(),"buy".into())]]))).await; + } let currency = money::service_currency(pool).await; let prices = sqlx::query( "SELECT currency, amount_minor FROM tariff_prices @@ -570,7 +615,7 @@ async fn show_methods( .await?; if !prices.iter().any(|r| r.get::("currency") == currency) { - tg.edit(chat, mid, "Цена не найдена.", None).await?; + tg.edit(chat, mid, "Цена больше недоступна. Откройте актуальные тарифы.", Some(keyboard(vec![vec![Btn::Data("К тарифам".into(),"buy".into())]]))).await?; return Ok(()); } @@ -652,26 +697,30 @@ async fn show_subscription(c: &Ctx<'_>, chat: i64, client_id: i64, mid: OptionВаша подписка\n\nСсылка для приложения:\n{url}\n\n\ Скопируйте её и добавьте как подписку в вашем VPN-приложении.\n\ Или откройте ссылку в браузере — там есть инструкция." ) } else { - format!("Подписка неактивна.\n\nСсылка появится сразу после оплаты.") + subscription_inactive_text(&info).to_string() }; - let kb = keyboard(vec![ + let mut rows = vec![ vec![Btn::Data("📱 Как подключить".into(), "apps".into())], vec![Btn::Data( if info.autorenew { "🔁 Автопродление: включено".into() } else { "🔁 Автопродление: выключено".into() }, "renew".into(), )], - vec![Btn::Url("🌐 Открыть страницу".into(), url)], - vec![Btn::Data("← Назад".into(), "menu".into())], - ]); + ]; + if subscription_active(&info) { rows.push(vec![Btn::Url("🌐 Открыть страницу".into(), url)]); } + if matches!(info.status.as_str(),"disabled"|"limited") { + rows.push(vec![Btn::Data("Поддержка".into(),"tickets".into())]); + } else if !subscription_active(&info) { rows.push(vec![Btn::Data("К тарифам".into(),"buy".into())]); } + rows.push(vec![Btn::Data("← Назад".into(),"menu".into())]); + let kb=keyboard(rows); tg.screen(chat, mid, &text, c.s.image(settings::Screen::Sub).as_deref(), Some(kb)) .await?; Ok(()) @@ -699,10 +748,8 @@ async fn on_message(c: &Ctx<'_>, msg: &Value) -> Result<()> { let client_id = ensure_client(pool, from).await?; sn_core::addons::refresh(pool,client_id).await?; - let info = sub_info(pool, client_id).await?; - let until = info.expires.map(|e| e.format("%d.%m.%Y").to_string()).unwrap_or_default(); let receipt = addon_receipt(pool, outcome.payment_id.unwrap_or(0), client_id).await? - .unwrap_or_else(||format!("✅ Оплата получена!\n\nПодписка активна до {until}.")); + .unwrap_or_else(||"✅ Оплата подтверждена. Состояние доступа и срок смотрите в разделе «Моя подписка».".into()); tg.send( chat, &receipt, @@ -843,14 +890,12 @@ async fn on_callback(c: &Ctx<'_>, cq: &Value) -> Result<()> { let (tg, pool, cfg, reg) = (c.tg, c.pool, c.cfg, c.reg); let _ = (cfg, reg); - let id = cq["id"].as_str().unwrap_or_default(); let chat = cq["message"]["chat"]["id"].as_i64().unwrap_or(0); let mid = cq["message"]["message_id"].as_i64().unwrap_or(0); let data = cq["data"].as_str().unwrap_or(""); let client_id = ensure_client(pool, &cq["from"]).await?; sqlx::query("DELETE FROM bot_await WHERE client_id=$1").bind(client_id).execute(pool).await?; - tg.answer_callback(id, None).await.ok(); let parts: Vec<&str> = data.split(':').collect(); if let Some(token)=data.strip_prefix("cablink:") { @@ -874,7 +919,7 @@ async fn on_callback(c: &Ctx<'_>, cq: &Value) -> Result<()> { ["renew"] => { // Переключаем и сразу показываем карточку заново: без этого // человек не видит, сработало ли нажатие. - let now: bool = sqlx::query_scalar( + let _now: bool = sqlx::query_scalar( "UPDATE subscriptions SET autorenew = NOT autorenew WHERE client_id = $1 AND is_current RETURNING autorenew", ) @@ -882,12 +927,6 @@ async fn on_callback(c: &Ctx<'_>, cq: &Value) -> Result<()> { .fetch_optional(pool) .await? .unwrap_or(false); - tg.answer_callback( - id, - Some(if now { "Автопродление включено" } else { "Автопродление выключено" }), - ) - .await - .ok(); show_subscription(c, chat, client_id, Some(mid)).await } ["ref"] => { @@ -942,7 +981,7 @@ async fn on_callback(c: &Ctx<'_>, cq: &Value) -> Result<()> { let days: i32 = days.parse().map_err(|_| Error::bad("плохой срок"))?; create_and_show_invoice(tg, pool, reg, chat, mid, client_id, tid, days, provider, currency).await } - _ => Ok(()), + _ => tg.edit(chat,mid,"Эта кнопка устарела. Откройте актуальное меню.",Some(recovery_keyboard())).await, } } @@ -963,7 +1002,7 @@ async fn create_and_show_invoice( let row = sqlx::query( "SELECT tp.amount_minor, t.title FROM tariff_prices tp JOIN tariffs t ON t.id = tp.tariff_id - WHERE tp.tariff_id = $1 AND tp.period_days = $2 AND tp.currency = $3 AND tp.is_active", + WHERE tp.tariff_id = $1 AND tp.period_days = $2 AND tp.currency = $3 AND tp.is_active AND t.is_active AND t.is_visible", ) .bind(tariff_id) .bind(days) @@ -1010,7 +1049,7 @@ async fn create_and_show_invoice( rows.push(vec![Btn::Url("💳 Оплатить".into(), invoice.pay_url.clone())]); } rows.push(vec![Btn::Data("🔄 Проверить оплату".into(), format!("c:{payment_id}"))]); - rows.push(vec![Btn::Data("← Отмена".into(), "buy".into())]); + rows.push(vec![Btn::Data("← К тарифам".into(), "buy".into())]); let instructions = invoice.payload["instructions"].as_str().unwrap_or(""); let body = if instructions.is_empty() { @@ -1028,10 +1067,11 @@ async fn create_and_show_invoice( tg.edit(chat, mid, &body, Some(keyboard(rows))).await } Err(e) => { + tracing::warn!(error=%e,client_id,"Не удалось создать счёт в боте"); tg.edit( chat, mid, - &format!("⚠️ Не удалось создать счёт.\n{e}"), + "⚠️ Не удалось подтвердить создание счёта. Не оплачивайте повторно. Если вы уже перешли к оплате, проверьте свой счёт позже или обратитесь в поддержку.", Some(keyboard(vec![vec![Btn::Data("← Назад".into(), "buy".into())]])), ) .await @@ -1053,42 +1093,54 @@ async fn check_payment( tg: &Tg, pool: &Pool, reg: &Registry, cfg: &Config, chat: i64, mid: i64, client_id: i64, payment_id: i64, ) -> Result<()> { - let row = sqlx::query( - "SELECT status::text AS status, provider, provider_txid - FROM payments WHERE id = $1 AND client_id = $2", - ) - .bind(payment_id) - .bind(client_id) - .fetch_optional(pool) - .await? - .ok_or_else(|| Error::bad("платёж не найден"))?; - - let _=row; - reg.check_payment(pool,payment_id).await?; - - sn_core::addons::refresh(pool,client_id).await?; - let info = sub_info(pool, client_id).await?; - let paid: bool = sqlx::query_scalar("SELECT status='success' FROM payments WHERE id=$1 AND client_id=$2") + // Ownership is checked before contacting a payment provider. + let owned:bool=sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM payments WHERE id=$1 AND client_id=$2)") .bind(payment_id).bind(client_id).fetch_one(pool).await?; - if paid { - let until = info.expires.map(|e| e.format("%d.%m.%Y").to_string()).unwrap_or_default(); + if !owned { + return tg.edit(chat,mid,"Счёт не найден. Откройте свой счёт или обратитесь в поддержку.",Some(recovery_keyboard())).await; + } + let uncertain=match reg.check_payment(pool,payment_id).await { + Ok(())=>false, + Err(error)=>{tracing::warn!(%error,payment_id,"Не удалось проверить оплату");true} + }; + let row=sqlx::query("SELECT status::text AS status,pay_url,kind::text AS kind,COALESCE(expires_at<=now(),false) AS expired FROM payments WHERE id=$1 AND client_id=$2") + .bind(payment_id).bind(client_id).fetch_one(pool).await?; + let status:String=row.get("status"); + if status=="success" { + sn_core::addons::refresh(pool,client_id).await?; let receipt=addon_receipt(pool,payment_id,client_id).await? - .unwrap_or_else(||format!("✅ Оплата подтверждена!\n\nПодписка активна до {until}.")); - tg.edit(chat, mid, - &receipt, - Some(keyboard(vec![vec![Btn::Data("🔑 Моя подписка".into(), "sub".into())]]))).await?; + .unwrap_or_else(||"✅ Оплата подтверждена. Состояние доступа и срок смотрите в разделе «Моя подписка».".into()); + tg.edit(chat,mid,&receipt,Some(keyboard(vec![vec![Btn::Data("🔑 Моя подписка".into(),"sub".into())]]))).await?; } else { - tg.edit(chat, mid, - "⏳ Оплата пока не поступила.\n\nЕсли вы уже заплатили — подождите минуту и нажмите ещё раз.", - Some(keyboard(vec![ - vec![Btn::Data("🔄 Проверить ещё раз".into(), format!("c:{payment_id}"))], - vec![Btn::Data("← Назад".into(), "buy".into())], - ]))).await?; + let back=if row.get::("kind")=="addon" {"addons"} else {"buy"}; + let (text,kb)=payment_wait_screen(&status,row.get::,_>("pay_url").as_deref(),row.get("expired"),uncertain,payment_id,back); + tg.edit(chat,mid,text,Some(kb)).await?; } - let _ = cfg; + let _=cfg; Ok(()) } +fn payment_wait_screen(status:&str,pay_url:Option<&str>,expired:bool,uncertain:bool,id:i64,back:&str)->(&'static str,Value) { + let text=if uncertain { + "Не удалось уточнить результат оплаты. Не оплачивайте повторно. Проверьте этот счёт позже или обратитесь в поддержку." + } else {match status { + "pending" if expired=>"Срок действия счёта истёк. Если вы уже оплатили, проверьте счёт позже или обратитесь в поддержку.", + "pending"=>"⏳ Оплата пока не поступила. Если вы уже заплатили — подождите минуту и нажмите ещё раз.", + "canceled"=>"Счёт отменён или срок его действия истёк. Если деньги списаны, обратитесь в поддержку.", + "failed"=>"Платёж завершился ошибкой. Если деньги списаны, обратитесь в поддержку.", + "refunded"=>"Платёж возвращён. По вопросам возврата обратитесь в поддержку.", + _=>"Статус счёта пока неизвестен. Не оплачивайте повторно. Обратитесь в поддержку.", + }}; + let mut rows=Vec::new(); + if status=="pending" && !expired && !uncertain { + if let Some(url)=pay_url.filter(|url|!url.is_empty()) {rows.push(vec![Btn::Url("💳 Оплатить".into(),url.into())]);} + } + if status!="refunded" {rows.push(vec![Btn::Data("🔄 Проверить ещё раз".into(),format!("c:{id}"))]);} + rows.push(vec![Btn::Data(if back=="addons" {"К пакетам"} else {"К тарифам"}.into(),back.into())]); + rows.push(vec![Btn::Data("Поддержка".into(),"tickets".into())]); + (text,keyboard(rows)) +} + #[cfg(test)] mod tests { use super::*; @@ -1344,3 +1396,109 @@ fn html_escape(text: &str) -> String { text.replace('&',"&").replace('<',"&l server.abort(); } } + +#[cfg(test)] mod recovery_tests { + use super::*; + use serde_json::json; + #[test] fn payment_states_keep_only_payable_invoice_links() { + let (text,kb)=payment_wait_screen("pending",Some("https://cash.example.test/old"),false,false,42,"buy"); + assert!(text.contains("пока не поступила")); + assert!(kb.to_string().contains("https://cash.example.test/old")); + assert!(kb.to_string().contains("c:42")); + for status in ["canceled","failed","refunded"] { + let (text,kb)=payment_wait_screen(status,Some("https://cash.example.test/old"),false,false,42,"addons"); + assert!(!text.contains("пока не поступила"));assert!(!kb.to_string().contains("https://cash.example.test/old")); + assert!(kb.to_string().contains("addons")); + } + let (text,kb)=payment_wait_screen("pending",Some("https://cash.example.test/old"),true,false,42,"buy"); + assert!(text.contains("истёк"));assert!(!kb.to_string().contains("https://cash.example.test/old")); + let (text,kb)=payment_wait_screen("pending",Some("https://cash.example.test/old"),false,true,42,"buy"); + assert!(text.contains("Не оплачивайте повторно"));assert!(!text.contains("пока не поступила"));assert!(kb.to_string().contains("c:42")); + } + #[test] fn subscription_states_do_not_promise_payment_unlocks_restrictions() { + let mut info=SubInfo{status:"active".into(),expires:None,used:0,limit:None,short_id:"test".into(),autorenew:false,tariff:None}; + assert!(subscription_active(&info)); + info.expires=Some(chrono::Utc::now()-chrono::Duration::days(1));assert!(!subscription_active(&info)); + for (status,word) in [("limited","ограничен"),("disabled","отключён"),("expired","истёк")] { + info.status=status.into();let text=subscription_inactive_text(&info);assert!(text.contains(word));assert!(!text.contains("после оплаты")); + } + info.expires=None;assert!(subscription_inactive_text(&info).contains("активации")); + } + #[tokio::test] async fn callback_database_failure_acknowledged_once_and_reports_safe_recovery() { + let pool=sqlx::postgres::PgPoolOptions::new().connect_lazy("postgres://unused:unused@127.0.0.1:1/unused").unwrap();pool.close().await; + let (tg,server)=tg::transport_tests::mock(vec![json!({"ok":true,"result":{"message_id":10}});2]).await; + let cfg=Config{database_url:String::new(),api_bind:String::new(),sub_bind:String::new(),sub_public_url:String::new(),brand_name:"QA".into(),web_root:String::new(),sub_mode:"db".into(),panel_url:String::new(),sub_service_token:None}; + let callback=json!({"callback_query":{"id":"failure","from":{"id":123},"message":{"chat":{"id":123,"type":"private"},"message_id":10},"data":"c:42"}}); + handle(&tg,&pool,&Registry::from_env(),&cfg,&settings::BotSettings,&callback).await.unwrap(); + let requests=server.await.unwrap();assert!(requests[0].0.ends_with("answerCallbackQuery"));assert!(requests[1].0.ends_with("sendMessage")); + assert!(requests[1].1["text"].as_str().unwrap().contains("Не оплачивайте повторно")); + assert!(!requests[1].1.to_string().contains("pool closed")); + } +} + +#[cfg(test)] mod recovery_database_tests { + use super::*; + use serde_json::json; + #[tokio::test] + #[ignore = "requires SN_TEST_DATABASE_URL pointing to an isolated sn_audit database"] + async fn recovery_callbacks_payment_states_and_ownership() -> Result<()> { + let url=std::env::var("SN_TEST_DATABASE_URL").expect("isolated DB required"); + let pool=sn_core::db::connect(&url).await?; + let db:String=sqlx::query_scalar("SELECT current_database()").fetch_one(&pool).await?;assert!(db.starts_with("sn_audit_")); + let cfg=Config{database_url:url,api_bind:String::new(),sub_bind:String::new(),sub_public_url:"https://sub.example.test".into(),brand_name:"QA".into(),web_root:String::new(),sub_mode:"db".into(),panel_url:String::new(),sub_service_token:None}; + let uid=9500000000+chrono::Utc::now().timestamp();let from=json!({"id":uid,"username":"qa_recovery"}); + let client=ensure_client(&pool,&from).await?; + let payment:i64=sqlx::query_scalar("INSERT INTO payments(client_id,amount_minor,currency,provider,pay_url) VALUES($1,100,'USD','manual','https://cash.example.test/same') RETURNING id").bind(client).fetch_one(&pool).await?; + let (tg,server)=tg::transport_tests::mock(vec![json!({"ok":true,"result":{"message_id":10}});13]).await; + let reg=Registry::from_env();let settings=settings::BotSettings; + let callback=|data:&str|json!({"callback_query":{"id":data,"from":from,"message":{"chat":{"id":uid,"type":"private"},"message_id":10},"data":data}}); + for data in ["renew","subchk","obsolete"] {handle(&tg,&pool,®,&cfg,&settings,&callback(data)).await?;} + for status in ["pending","canceled","failed","refunded"] { + sqlx::query("UPDATE payments SET status=$2::payment_status WHERE id=$1").bind(payment).bind(status).execute(&pool).await?; + check_payment(&tg,&pool,®,&cfg,uid,10,client,payment).await?; + } + // Expired pending invoice must not offer checkout; foreign IDs never reveal details. + sqlx::query("UPDATE payments SET status='pending',expires_at=now()-interval '1 day' WHERE id=$1").bind(payment).execute(&pool).await?; + check_payment(&tg,&pool,®,&cfg,uid,10,client,payment).await?; + check_payment(&tg,&pool,®,&cfg,uid,10,client+100000000,payment).await?; + check_payment(&tg,&pool,®,&cfg,uid,10,client,payment).await?; + let requests=server.await.unwrap(); + for (index,id) in ["renew","subchk","obsolete"].iter().enumerate() { + assert_eq!(requests.iter().filter(|(path,body)|path.ends_with("answerCallbackQuery")&&body["callback_query_id"]==*id).count(),1); + assert!(requests[index*2].0.ends_with("answerCallbackQuery")); + } + assert!(requests[5].1["text"].as_str().unwrap().contains("устарела")); + assert!(requests[6].1["reply_markup"].to_string().contains("https://cash.example.test/same")); + for index in 7..=10 {assert!(!requests[index].1["reply_markup"].to_string().contains("https://cash.example.test/same"));} + assert!(requests[11].1["text"].as_str().unwrap().contains("не найден")); + let count:i64=sqlx::query_scalar("SELECT count(*) FROM payments WHERE client_id=$1").bind(client).fetch_one(&pool).await?;assert_eq!(count,1); + sqlx::query("UPDATE clients SET deleted_at=now() WHERE id=$1").bind(client).execute(&pool).await?; + Ok(()) + } +} + +#[cfg(test)] mod stars_receipt_tests { + use super::*; + use serde_json::json; + #[tokio::test] + #[ignore = "requires SN_TEST_DATABASE_URL pointing to an isolated sn_audit database"] + async fn stars_receipt_does_not_promise_active_access_for_disabled_client() -> Result<()> { + let url=std::env::var("SN_TEST_DATABASE_URL").expect("isolated DB required"); + let pool=sn_core::db::connect(&url).await?; + let db:String=sqlx::query_scalar("SELECT current_database()").fetch_one(&pool).await?;assert!(db.starts_with("sn_audit_")); + let uid=9600000000+chrono::Utc::now().timestamp();let from=json!({"id":uid,"username":"qa_disabled_stars"}); + let client=ensure_client(&pool,&from).await?; + sqlx::query("UPDATE clients SET status='disabled' WHERE id=$1").bind(client).execute(&pool).await?; + // Already applied receipt isolates presentation and verifies duplicate delivery preserves rights. + let payment:i64=sqlx::query_scalar("INSERT INTO payments(client_id,status,amount_minor,currency,provider) VALUES($1,'success',20,'XTR','stars') RETURNING id").bind(client).fetch_one(&pool).await?; + let (tg,server)=tg::transport_tests::mock(vec![json!({"ok":true,"result":{"message_id":10}})]).await; + let cfg=Config{database_url:url,api_bind:String::new(),sub_bind:String::new(),sub_public_url:String::new(),brand_name:"QA".into(),web_root:String::new(),sub_mode:"db".into(),panel_url:String::new(),sub_service_token:None}; + let reg=Registry::from_env();let c=Ctx{tg:&tg,pool:&pool,reg:®,cfg:&cfg,s:settings::View::from_pairs(&[])}; + on_message(&c,&json!({"chat":{"id":uid},"from":from,"successful_payment":{"invoice_payload":payment.to_string(),"total_amount":20,"currency":"XTR","telegram_payment_charge_id":format!("qa-disabled-{payment}")}})).await?; + let requests=server.await.unwrap();let text=requests[0].1["text"].as_str().unwrap(); + assert!(text.contains("Оплата подтверждена"));assert!(!text.contains("Подписка активна"));assert!(text.contains("Моя подписка")); + let status:String=sqlx::query_scalar("SELECT status::text FROM clients WHERE id=$1").bind(client).fetch_one(&pool).await?;assert_eq!(status,"disabled"); + sqlx::query("UPDATE clients SET deleted_at=now() WHERE id=$1").bind(client).execute(&pool).await?; + Ok(()) + } +} diff --git a/crates/core/src/alerts.rs b/crates/core/src/alerts.rs index 4302ad8..87fc034 100644 --- a/crates/core/src/alerts.rs +++ b/crates/core/src/alerts.rs @@ -2,6 +2,8 @@ use crate::{bot_config as bc,Error,Pool,Result}; use serde_json::{json,Value,Map}; use sqlx::Row; +#[path="alerts_load.rs"] +mod load; pub fn destination(s:&Map)->Result<(String,Option)> { let chat=bc::text(s,"bot.alert_chat_id",""); @@ -71,13 +73,17 @@ pub async fn scan_nodes(pool:&Pool)->Result<()> { let rows=sqlx::query("SELECT n.id,n.name,n.engine_ok,n.status::text AS status,COALESCE(n.last_seen_atnow()-interval '5 minutes' ORDER BY at DESC LIMIT 1)m ON true WHERE n.deleted_at IS NULL AND n.notify AND n.status::text<>'disabled'").fetch_all(pool).await?; for row in rows { let stale:bool=row.get("stale");let engine:bool=row.get("engine_ok"); - let cpu=row.try_get::,_>("cpu_percent").ok().flatten().unwrap_or(0.0);let ram=row.try_get::,_>("ram_percent").ok().flatten().unwrap_or(0.0); + let cpu=row.try_get::,_>("cpu_percent").ok().flatten();let ram=row.try_get::,_>("ram_percent").ok().flatten(); let problem=if stale {Some("Нет отчёта агента более трёх минут")}else if !engine{Some("Агент сообщает об ошибке VPN-движка")}else{None}; let id:i64=row.get("id");let title=format!("Нода {}",row.get::("name")); incident(pool,&format!("node:{id}"),&title,problem).await?; let previous:Option=sqlx::query_scalar("SELECT problem FROM service_incidents WHERE key=$1").bind(format!("load:{id}")).fetch_optional(pool).await?.flatten(); - let overloaded=cpu>=95.0||ram>=95.0||(previous.is_some()&&(cpu>=85.0||ram>=85.0)); - incident(pool,&format!("load:{id}"),&title,if !stale&&overloaded{Some("Нагрузка CPU или памяти выше 95%. Восстановление — ниже 85%.")}else{None}).await?; + // Missing/stale metrics cannot prove recovery. Keep the durable incident unchanged. + if let Some(overloaded)=load::load_observation(stale,cpu,ram,previous.is_some()) { + // Preserve the active detail to avoid a new notification solely after a wording upgrade. + let detail=previous.as_deref().unwrap_or("Нормализованная нагрузка (load average / число CPU) или память от 95%. Это не CPU utilization. Восстановление — ниже 85%."); + incident(pool,&format!("load:{id}"),&title,if overloaded{Some(detail)}else{None}).await?; + } } Ok(()) } diff --git a/crates/core/src/alerts_load.rs b/crates/core/src/alerts_load.rs new file mode 100644 index 0000000..bfb5239 --- /dev/null +++ b/crates/core/src/alerts_load.rs @@ -0,0 +1,38 @@ +//! Load-state decisions. `None` means there is no observation to apply. +pub fn load_observation(stale: bool, cpu: Option, ram: Option, active: bool) -> Option { + if stale { return None; } + let (cpu, ram) = (cpu?, ram?); + if !cpu.is_finite() || !ram.is_finite() { return None; } + Some(cpu >= 95.0 || ram >= 95.0 || (active && (cpu >= 85.0 || ram >= 85.0))) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn stale_samples_never_resolve_or_open_an_incident() { + for active in [false, true] { + for value in [20.0, 99.0] { + assert_eq!(load_observation(true, Some(value), Some(value), active), None); + } + } + } + #[test] + fn missing_or_nonfinite_samples_are_unknown() { + for active in [false, true] { + for missing in [None, Some(f32::NAN), Some(f32::INFINITY)] { + assert_eq!(load_observation(false, missing, Some(20.0), active), None); + assert_eq!(load_observation(false, Some(20.0), missing, active), None); + } + } + } + #[test] + fn fresh_samples_preserve_existing_thresholds_and_hysteresis() { + assert_eq!(load_observation(false, Some(95.0), Some(20.0), false), Some(true)); + assert_eq!(load_observation(false, Some(20.0), Some(95.0), false), Some(true)); + assert_eq!(load_observation(false, Some(94.9), Some(20.0), false), Some(false)); + assert_eq!(load_observation(false, Some(85.0), Some(20.0), true), Some(true)); + assert_eq!(load_observation(false, Some(20.0), Some(85.0), true), Some(true)); + assert_eq!(load_observation(false, Some(84.9), Some(84.9), true), Some(false)); + } +} diff --git a/crates/core/src/alerts_tests.rs b/crates/core/src/alerts_tests.rs index ccdf7b6..0c8d4b7 100644 --- a/crates/core/src/alerts_tests.rs +++ b/crates/core/src/alerts_tests.rs @@ -59,6 +59,26 @@ async fn team_alert_delivery_lifecycle()->Result<()> { sqlx::query("INSERT INTO node_metrics(node_id,cpu_percent,ram_percent) VALUES($1,96,20)").bind(node).execute(&pool).await?; scan_nodes(&pool).await?; let problem:Option=sqlx::query_scalar("SELECT problem FROM service_incidents WHERE key=$1").bind(format!("node:{node}")).fetch_one(&pool).await?;assert!(problem.is_none()); + // An open load incident must survive stale reports, missing fields, stale + // measurements and a temporarily absent node without creating a recovery. + let load_key=format!("load:{node}"); + let revision:i64=sqlx::query_scalar("SELECT revision FROM service_incidents WHERE key=$1").bind(&load_key).fetch_one(&pool).await?; + for mutation in [ + "UPDATE nodes SET last_seen_at=now()-interval '4 minutes' WHERE id=$1", + "UPDATE nodes SET last_seen_at=now() WHERE id=$1", + "UPDATE node_metrics SET cpu_percent=NULL WHERE node_id=$1", + "UPDATE node_metrics SET cpu_percent=96,ram_percent=NULL WHERE node_id=$1", + "UPDATE node_metrics SET ram_percent=20,at=now()-interval '6 minutes' WHERE node_id=$1", + "UPDATE nodes SET deleted_at=now() WHERE id=$1", + ] { + sqlx::query(mutation).bind(node).execute(&pool).await?; + scan_nodes(&pool).await?;scan_nodes(&pool).await?; + let state:(Option,i64)=sqlx::query_as("SELECT problem,revision FROM service_incidents WHERE key=$1").bind(&load_key).fetch_one(&pool).await?; + assert!(state.0.is_some(), "unknown measurements cannot recover: {mutation}"); + assert_eq!(state.1,revision,"unknown/repeated samples cannot enqueue transitions: {mutation}"); + } + sqlx::query("UPDATE nodes SET deleted_at=NULL WHERE id=$1").bind(node).execute(&pool).await?; + sqlx::query("UPDATE node_metrics SET at=now() WHERE node_id=$1").bind(node).execute(&pool).await?; for cpu in [90.0f32,80.0] {sqlx::query("UPDATE node_metrics SET cpu_percent=$2 WHERE node_id=$1").bind(node).bind(cpu).execute(&pool).await?;scan_nodes(&pool).await?; let problem:Option=sqlx::query_scalar("SELECT problem FROM service_incidents WHERE key=$1").bind(format!("load:{node}")).fetch_one(&pool).await?;assert_eq!(problem.is_some(),cpu>=85.0);} sqlx::query("UPDATE nodes SET deleted_at=now() WHERE id=$1").bind(node).execute(&pool).await?; diff --git a/crates/payments/src/providers/hosted.rs b/crates/payments/src/providers/hosted.rs index 1c1197e..8f21271 100644 --- a/crates/payments/src/providers/hosted.rs +++ b/crates/payments/src/providers/hosted.rs @@ -36,7 +36,7 @@ impl Hosted { if req.amount_minor<=0 || !self.currencies().contains(&req.currency){return Err(Error::bad("валюта или сумма не поддерживается платёжным модулем"));} let amount=crate::minor_to_units(req.amount_minor,&req.currency); let order=format!("sn-{}",req.payment_id); - let return_url=self.cfg.get("return_url"); + let return_url=req.return_url.clone().or_else(||self.cfg.get("return_url")); let mut body=match self.kind { Gateway::Platega=>json!({"paymentDetails":{"amount":serde_json::from_str::(&amount).map_err(|_|Error::bad("сумма"))?,"currency":req.currency},"description":req.description,"payload":order,"metadata":{"userId":req.telegram_id.unwrap_or(req.client_id).to_string()}}), Gateway::RollyPay=>{ @@ -87,6 +87,7 @@ fn text<'a>(v:&'a Value,key:&str)->Result<&'a str>{v.get(key).and_then(Value::as pub(super) fn amount_minor(v:&Value)->Result{ let raw=match v {Value::String(s)=>s.clone(),Value::Number(n)=>n.to_string(),_=>return Err(Error::bad("нет суммы платежа"))}; let (whole,fraction)=raw.split_once('.').unwrap_or((&raw,"")); + let fraction=fraction.trim_end_matches('0'); if whole.is_empty() || !whole.bytes().all(|b|b.is_ascii_digit()) || fraction.len()>2 || !fraction.bytes().all(|b|b.is_ascii_digit()){return Err(Error::bad("некорректная сумма платежа"));} let w=whole.parse::().map_err(|_|Error::bad("сумма слишком велика"))?; let f=if fraction.is_empty(){0}else{fraction.parse::().map_err(|_|Error::bad("сумма"))? * if fraction.len()==1{10}else{1}}; @@ -202,6 +203,41 @@ mod tests { let p=configured(Gateway::RollyPay);let mut v=notification(Gateway::RollyPay);v["test"]=json!(true);let b=serde_json::to_vec(&v).unwrap();assert!(p.handle_webhook(&signed_headers(Gateway::RollyPay,&v,&b),&b).await.is_err()); let mut v=notification(Gateway::ParityPay);v["shop_id"]=json!("different");let b=serde_json::to_vec(&v).unwrap();assert!(configured(Gateway::ParityPay).handle_webhook(&signed_headers(Gateway::ParityPay,&v,&b),&b).await.is_err()); } + fn return_url_cases()->[(Gateway,&'static str,[&'static str;2]);4] { + [ + (Gateway::Platega,"RUB",["return","failedUrl"]), + (Gateway::RollyPay,"RUB",["success_redirect_url","fail_redirect_url"]), + (Gateway::RollyPay,"EUR",["success_redirect_url","fail_redirect_url"]), + (Gateway::ParityPay,"RUB",["success_url","fail_url"]), + ] + } + #[test] fn invoice_return_url_prefers_request_over_provider_default(){ + for (kind,currency,fields) in return_url_cases() { + for default in [Some("https://service.example/app/"),None] { + let provider=configured(kind); + if let Some(url)=default {let mut cfg=provider.cfg.snapshot();cfg.insert("return_url".into(),json!(url));provider.cfg.replace(cfg);} + let req=InvoiceRequest{payment_id:77,client_id:5,telegram_id:None,amount_minor:17500,currency:currency.into(),description:"Test plan".into(),return_url:Some("https://cabinet.example/#payments".into())}; + let body=provider.body(&req).unwrap(); + for field in fields {assert_eq!(body[field],json!("https://cabinet.example/#payments"),"{kind:?} {currency} {field}, configured default: {default:?}");} + } + } + } + #[test] fn invoice_return_url_falls_back_to_provider_default(){ + for (kind,currency,fields) in return_url_cases() { + let provider=configured(kind); + let mut cfg=provider.cfg.snapshot();cfg.insert("return_url".into(),json!("https://service.example/app/"));provider.cfg.replace(cfg); + let req=InvoiceRequest{payment_id:77,client_id:5,telegram_id:Some(11),amount_minor:17500,currency:currency.into(),description:"Test plan".into(),return_url:None}; + let body=provider.body(&req).unwrap(); + for field in fields {assert_eq!(body[field],json!("https://service.example/app/"),"{kind:?} {currency} {field}");} + } + } + #[test] fn invoice_return_url_is_omitted_when_unset(){ + for (kind,currency,fields) in return_url_cases() { + let req=InvoiceRequest{payment_id:77,client_id:5,telegram_id:Some(11),amount_minor:17500,currency:currency.into(),description:"Test plan".into(),return_url:None}; + let body=configured(kind).body(&req).unwrap(); + for field in fields {assert!(body.get(field).is_none(),"{kind:?} {currency}: unexpected {field}");} + } + } #[test] fn decimal_and_invoice_contracts(){ for (v,n) in [(json!("19.99"),1999),(json!(19.9),1990),(json!("0.01"),1),(json!(1200),120000)]{assert_eq!(amount_minor(&v).unwrap(),n);} for v in [json!("19.999"),json!("-1"),json!("NaN"),json!("1e9"),Value::Null,json!("999999999999999999999")]{assert!(amount_minor(&v).is_err());} @@ -230,4 +266,136 @@ mod tests { assert!(requests[0].starts_with(&format!("POST {post_path} ")));assert!(requests[1].starts_with(&format!("GET {get_path} ")));for r in &requests{assert!(r.to_lowercase().contains(header));} } } + + #[test] + fn sn_b05_exact_decimal_matrix() { + for (raw,expected) in [("0",0),("0.00000000",0),("19.9",1990),("19.990",1999),("19.99000000",1999),("0.01000000",1),("1.00000000",100),("92233720368547758.07000000",i64::MAX)] { + assert_eq!(amount_minor(&json!(raw)).unwrap(),expected,"{raw}"); + } + for (raw,expected) in [("0.00000000",0),("19.99000000",1999),("19.90000000",1990)] { + assert_eq!(amount_minor(&serde_json::from_str::(raw).unwrap()).unwrap(),expected); + } + } + #[test] + fn sn_b05_reject_fractional_cents_and_overflow() { + for raw in ["19.99900000","0.00000001","-1","-0.00","+1"," 1","1e2","1..00",".01","NaN","92233720368547758.08","9223372036854775808.00","1.00x00"] { + assert!(amount_minor(&json!(raw)).is_err(),"{raw}"); + } + for v in [Value::Null,json!(-1),json!(19.999),json!(true)] {assert!(amount_minor(&v).is_err());} + } + #[tokio::test] + async fn sn_b05_signed_statuses_keep_authentication() { + let p=configured(Gateway::RollyPay); + for (status,expected) in [("created",PaymentStatus::Pending),("processing",PaymentStatus::Pending),("paid",PaymentStatus::Success),("expired",PaymentStatus::Canceled),("canceled",PaymentStatus::Canceled)] { + let mut v=notification(Gateway::RollyPay);v["amount"]=json!("19.99000000");v["status"]=json!(status); + let b=serde_json::to_vec(&v).unwrap();let h=signed_headers(Gateway::RollyPay,&v,&b); + let out=p.handle_webhook(&h,&b).await.unwrap();assert_eq!(out.status,expected);assert_eq!(out.amount_minor,Some(1999)); + assert!(p.handle_webhook(&HashMap::new(),&b).await.is_err()); + let mut bad=v.clone();bad["amount"]=json!("29.99000000");assert!(p.handle_webhook(&h,&serde_json::to_vec(&bad).unwrap()).await.is_err()); + bad=v.clone();bad["currency"]=json!("USD");let bytes=serde_json::to_vec(&bad).unwrap();assert!(p.handle_webhook(&signed_headers(Gateway::RollyPay,&bad,&bytes),&bytes).await.is_err()); + } + } + #[tokio::test] + async fn sn_b05_poll_exact_amount_over_loopback() { + use tokio::io::{AsyncReadExt,AsyncWriteExt}; + let listener=tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();let addr=listener.local_addr().unwrap(); + let server=tokio::spawn(async move { + let (mut s,_)=listener.accept().await.unwrap();let mut raw=vec![];let mut buf=[0;4096]; + while !raw.windows(4).any(|x|x==b"\r\n\r\n") {let n=s.read(&mut buf).await.unwrap();assert!(n>0);raw.extend_from_slice(&buf[..n]);} + let body=json!({"payment_id":"pay-test","order_id":"sn-77","amount":"19.99000000","payment_currency":"RUB","status":"paid"}).to_string(); + s.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",body.len()).as_bytes()).await.unwrap(); + assert!(String::from_utf8(raw).unwrap().starts_with("GET /api/v1/payments/pay-test ")); + }); + let mut p=configured(Gateway::RollyPay);p.base=format!("http://{addr}"); + let result=p.poll_outcome("pay-test").await;server.await.unwrap();let out=result.unwrap().unwrap();assert_eq!(out.amount_minor,Some(1999));assert_eq!(out.payment_id,Some(77)); + } + #[tokio::test] + #[ignore = "Requires isolated SN_TEST_DATABASE_URL; local synthetic notifications only"] + async fn sn_b05_repeated_notification_preserves_invoice_and_term() { + let url=std::env::var("SN_TEST_DATABASE_URL").expect("isolated database required");let pool=sn_core::db::connect(&url).await.unwrap(); + let name:String=sqlx::query_scalar("SELECT current_database()").fetch_one(&pool).await.unwrap();assert!(name.starts_with("sn_audit_")); + let tag=uuid::Uuid::new_v4().to_string();let tariff:i64=sqlx::query_scalar("SELECT id FROM tariffs WHERE code='CLOUD_AUDIT_PAID'").fetch_one(&pool).await.unwrap(); + let client:i64=sqlx::query_scalar("INSERT INTO clients(username,short_id) VALUES('sn_b05_fixture',$1) RETURNING id").bind(&tag).fetch_one(&pool).await.unwrap(); + sqlx::query("INSERT INTO subscriptions(client_id,tariff_id,expires_at) VALUES($1,$2,now())").bind(client).bind(tariff).execute(&pool).await.unwrap(); + let id:i64=sqlx::query_scalar("INSERT INTO payments(client_id,tariff_id,kind,status,amount_minor,currency,period_days,provider,provider_txid) VALUES($1,$2,'renewal','pending',1999,'RUB',30,'rollypay',$3) RETURNING id").bind(client).bind(tariff).bind(&tag).fetch_one(&pool).await.unwrap(); + let mut v=notification(Gateway::RollyPay);v["order_id"]=json!(format!("sn-{id}"));v["payment_id"]=json!(&tag);v["amount"]=json!("19.99000000"); + let p=configured(Gateway::RollyPay);let bytes=serde_json::to_vec(&v).unwrap();let mut out=p.handle_webhook(&signed_headers(Gateway::RollyPay,&v,&bytes),&bytes).await.unwrap(); + let reg=crate::Registry::from_env();reg.sync_to_db(&pool).await.unwrap(); + out.amount_minor=Some(1998);assert!(reg.apply_outcome(&pool,"rollypay",&out).await.is_err()); + out.amount_minor=Some(1999);out.currency=Some("EUR".into());assert!(reg.apply_outcome(&pool,"rollypay",&out).await.is_err()); + out.currency=Some("RUB".into());out.provider_txid=Some("wrong-invoice".into());assert!(reg.apply_outcome(&pool,"rollypay",&out).await.is_err()); + out.provider_txid=Some(tag.clone());reg.apply_outcome(&pool,"rollypay",&out).await.unwrap(); + let expiry:chrono::DateTime=sqlx::query_scalar("SELECT expires_at FROM subscriptions WHERE client_id=$1 AND is_current").bind(client).fetch_one(&pool).await.unwrap(); + reg.apply_outcome(&pool,"rollypay",&out).await.unwrap(); + let after:chrono::DateTime=sqlx::query_scalar("SELECT expires_at FROM subscriptions WHERE client_id=$1 AND is_current").bind(client).fetch_one(&pool).await.unwrap();assert_eq!(expiry,after); + let n:i64=sqlx::query_scalar("SELECT count(*) FROM payments WHERE client_id=$1 AND status='success'").bind(client).fetch_one(&pool).await.unwrap();assert_eq!(n,1); + println!("SN-B05 real DB: signed decimal notification settled once; amount/currency/invoice guards and duplicate term preserved"); + } + + #[tokio::test] + #[ignore = "Requires SN_COMBINED_FIXTURE pointing to isolated real API, database and synthetic web account"] + async fn combined_amount_http_notification_and_return_contract() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let fixture: Value = serde_json::from_slice(&std::fs::read( + std::env::var("SN_COMBINED_FIXTURE").expect("combined fixture must be configured") + ).unwrap()).unwrap(); + let db_url=fixture["database_url"].as_str().unwrap(); + let pool=sn_core::db::connect(db_url).await.unwrap(); + let db_name:String=sqlx::query_scalar("SELECT current_database()").fetch_one(&pool).await.unwrap(); + assert!(db_name.starts_with("sn_audit_r3_web_")); + let api=fixture["api_url"].as_str().unwrap();assert!(api.starts_with("http://127.0.0.1:")); + let web_return=format!("{}/#payments",fixture["cabinet_url"].as_str().unwrap()); + let tariff=fixture["tariff_id"].as_i64().unwrap(); + let http=reqwest::Client::new();let mut proofs=vec![]; + for channel in ["web","bot","miniapp"] { + let tag=uuid::Uuid::new_v4().simple().to_string(); + let client=if channel=="web" {fixture["web_client_id"].as_i64().unwrap()} else { + let id:i64=sqlx::query_scalar("INSERT INTO clients(username,short_id) VALUES($1,$2) RETURNING id").bind(format!("combined_{channel}")).bind(&tag).fetch_one(&pool).await.unwrap(); + sqlx::query("INSERT INTO subscriptions(client_id,tariff_id) VALUES($1,$2)").bind(id).bind(tariff).execute(&pool).await.unwrap();id + }; + sqlx::query("UPDATE subscriptions SET expires_at=now()+interval '1 day',tariff_id=$2 WHERE client_id=$1 AND is_current").bind(client).bind(tariff).execute(&pool).await.unwrap(); + let before:chrono::DateTime=sqlx::query_scalar("SELECT expires_at FROM subscriptions WHERE client_id=$1 AND is_current").bind(client).fetch_one(&pool).await.unwrap(); + let payment:i64=sqlx::query_scalar("INSERT INTO payments(client_id,tariff_id,kind,status,amount_minor,currency,period_days,provider) VALUES($1,$2,'renewal','pending',1999,'RUB',30,'rollypay') RETURNING id").bind(client).bind(tariff).fetch_one(&pool).await.unwrap(); + let listener=tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();let addr=listener.local_addr().unwrap(); + let response=json!({"payment_id":tag,"pay_url":"https://checkout.example.test/synthetic"}).to_string(); + let server=tokio::spawn(async move { + let (mut stream,_)=listener.accept().await.unwrap();let mut raw=vec![];let mut buf=[0;4096]; + loop {let n=stream.read(&mut buf).await.unwrap();assert!(n>0);raw.extend_from_slice(&buf[..n]); + if let Some(at)=raw.windows(4).position(|x|x==b"\r\n\r\n") { + let h=String::from_utf8_lossy(&raw[..at]);let size=h.lines().find_map(|x|x.to_lowercase().strip_prefix("content-length:").and_then(|n|n.trim().parse::().ok())).unwrap(); + if raw.len()>=at+4+size {break;} + } + } + assert!(raw.starts_with(b"POST /api/v1/payments HTTP/1.1\r\n")); + let at=raw.windows(4).position(|x|x==b"\r\n\r\n").unwrap();let body:Value=serde_json::from_slice(&raw[at+4..]).unwrap(); + stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}",response.len()).as_bytes()).await.unwrap();body + }); + let mut p=configured(Gateway::RollyPay);p.base=format!("http://{addr}"); + let fallback=if channel=="bot" {"https://t.me/synthetic_test_bot"} else {"https://miniapp.example.test/app/"}; + let mut cfg=p.cfg.snapshot();cfg.insert("return_url".into(),json!(fallback));p.cfg.replace(cfg); + let req=InvoiceRequest{payment_id:payment,client_id:client,telegram_id:if channel=="web"{None}else{Some(424242)},amount_minor:1999,currency:"RUB".into(),description:"Combined synthetic renewal".into(),return_url:if channel=="web"{Some(web_return.clone())}else{None}}; + let invoice=p.create_invoice(&req).await.unwrap();assert_eq!(invoice.external_id.as_deref(),Some(tag.as_str())); + let captured=server.await.unwrap();let expected_return=if channel=="web"{web_return.as_str()}else{fallback}; + assert_eq!(captured["amount"],"19.99");assert_eq!(captured["order_id"],format!("sn-{payment}")); + for field in ["success_redirect_url","fail_redirect_url"]{assert_eq!(captured[field],expected_return);} + sqlx::query("UPDATE payments SET provider_txid=$2,pay_url=$3 WHERE id=$1").bind(payment).bind(&tag).bind(&invoice.pay_url).execute(&pool).await.unwrap(); + let mut notification=notification(Gateway::RollyPay);notification["payment_id"]=json!(tag);notification["order_id"]=json!(format!("sn-{payment}"));notification["amount"]=json!("19.99000000"); + let bytes=serde_json::to_vec(¬ification).unwrap();let headers=signed_headers(Gateway::RollyPay,¬ification,&bytes); + let url=format!("{api}/api/pay/webhook/rollypay"); + let send=|data:Vec,signed:HashMap| {let mut r=http.post(&url).header("Content-Type","application/json").body(data);for (k,v) in signed {r=r.header(k,v);}r.send()}; + let bad=send(bytes.clone(),HashMap::new()).await.unwrap();assert!(!bad.status().is_success()); + let mut wrong=notification.clone();wrong["amount"]=json!("19.98000000");let wrong_bytes=serde_json::to_vec(&wrong).unwrap(); + assert!(!send(wrong_bytes.clone(),signed_headers(Gateway::RollyPay,&wrong,&wrong_bytes)).await.unwrap().status().is_success()); + let unchanged:chrono::DateTime=sqlx::query_scalar("SELECT expires_at FROM subscriptions WHERE client_id=$1 AND is_current").bind(client).fetch_one(&pool).await.unwrap();assert_eq!(before,unchanged); + let first=send(bytes.clone(),headers.clone()).await.unwrap();assert!(first.status().is_success(),"valid exact amount rejected: {}",first.status());assert_eq!(first.json::().await.unwrap()["ok"],true); + let once:chrono::DateTime=sqlx::query_scalar("SELECT expires_at FROM subscriptions WHERE client_id=$1 AND is_current").bind(client).fetch_one(&pool).await.unwrap();assert_eq!(once,before+chrono::Duration::days(30)); + let (a,b,c)=tokio::join!(send(bytes.clone(),headers.clone()),send(bytes.clone(),headers.clone()),send(bytes.clone(),headers.clone())); + for response in [a,b,c]{assert!(response.unwrap().status().is_success());} + let after:chrono::DateTime=sqlx::query_scalar("SELECT expires_at FROM subscriptions WHERE client_id=$1 AND is_current").bind(client).fetch_one(&pool).await.unwrap();assert_eq!(after,once); + let rows:Vec<(i64,String,i64)>=sqlx::query_as("SELECT id,status::text,amount_minor FROM payments WHERE client_id=$1 ORDER BY id").bind(client).fetch_all(&pool).await.unwrap();assert_eq!(rows,vec![(payment,"success".to_string(),1999)]); + proofs.push(json!({"channel":channel,"client_id":client,"payment_id":payment,"request_amount":captured["amount"],"notification_amount":"19.99000000","return_url":expected_return,"before":before,"after_first":once,"after_three_concurrent_repeats":after,"payment_rows":1,"signature_and_wrong_amount_rejected":true})); + } + std::fs::write(fixture["proof_file"].as_str().unwrap(),serde_json::to_vec_pretty(&json!({"cases":proofs,"provider_transport":"loopback Hosted create_invoice mock","notification_transport":"real HTTP API webhook","invoice_database_setup":"synthetic pending row; checkout API creation itself not exercised"})).unwrap()).unwrap(); + println!("COMBINED FLOW COMPLETE: web override, bot/Mini App fallback, local invoice create, signed real HTTP notification, 30 days once, 3 concurrent repeats, one invoice per account"); + } } diff --git a/crates/sub/src/http_tests.rs b/crates/sub/src/http_tests.rs index 5c5a246..2e84153 100644 --- a/crates/sub/src/http_tests.rs +++ b/crates/sub/src/http_tests.rs @@ -65,3 +65,101 @@ async fn short_and_legacy_urls_locales_qr_and_vpn_formats() { assert_eq!(http.get(format!("{url}/ready")).send().await.unwrap().status(), 200); task.abort(); panel_task.abort(); } + +#[tokio::test] +async fn early_errors_preserve_status_and_machine_contract_without_reflection() { + use axum::body::to_bytes; + for (error, status) in [(Error::NotFound, 404), (Error::Internal("secret https://panel.invalid/token ","status":status,"expires_at":"2020-01-01T00:00:00Z","traffic_used_bytes":5,"traffic_limit_bytes":10,"device_limit":1,"vpn_uuid":"00000000-0000-4000-8000-000000000001","hosts":if id=="no_hosts" || id=="custom" {json!([])} else {json!([{"remark":"Server","address":"node.example.test","port":443,"protocol":"vless","security":"none","network":"tcp"}])},"external_squad":{"settings":settings}})).into_response() + }})) + .route("/api/sub/{id}/device", post(|Path(id): Path| async move {Json(json!({"over_limit":id=="devices","used":1,"limit":1}))})) + .route("/api/sub/{id}/request", post(|| async { Json(json!({"ok":true})) })); + let (panel_url, panel_task) = serve(panel).await; + let config = Config { database_url:String::new(), api_bind:String::new(), sub_bind:String::new(),sub_public_url:"https://sub.example.test".into(), brand_name:"Test".into(),web_root:String::new(), sub_mode:"api".into(),panel_url:panel_url.clone(),sub_service_token:Some("mock-token".into()) }; + let state = SubState { source:Source::Api {panel_url, token:"mock-token".into(),http:reqwest::Client::new()}, config, settings:Default::default() }; + let (url, task) = serve(subscription_router(state)).await; + let http = reqwest::Client::new(); + for (id, status) in [("missing",404), ("failure",500)] { + for ua in ["Mozilla/5.0 (iPhone) Safari Telegram", "Mozilla/5.0 (Android) Telegram", "Mozilla/5.0 Safari"] { + for lang in ["ru","en"] { + let r=http.get(format!("{url}/s/{id}?lang={lang}")).header(header::USER_AGENT,ua).header(header::ACCEPT,"text/html").send().await.unwrap(); + assert_eq!(r.status().as_u16(),status); + assert_eq!(r.headers()[header::CONTENT_TYPE],"text/html; charset=utf-8"); + assert_eq!(r.headers()[header::CONTENT_LANGUAGE],lang); + let body=r.text().await.unwrap(); + assert!(body.contains(if lang=="ru" {"Не удалось открыть подписку"} else {"Unable to open subscription"})); + assert!(!body.contains("private upstream")); + } + } + } + for (id, english) in [("expired","Subscription expired"),("limited","Data allowance"),("disabled","Access suspended"),("no_hosts","No locations"),("devices","Devices used"),("unsupported","device ID"),("custom","Custom ")] { + for ua in ["curl/8", "Happ", "v2rayNG", "Clash", "Mihomo", "Stash", "sing-box", "RuleClient"] { + let mut payload=None; + for lang in ["ru","en"] { + let mut request=http.get(format!("{url}/{id}?lang={lang}")).header(header::USER_AGENT,ua); + if id=="devices" {request=request.header("x-hwid","fixture-device");} + let r=request.send().await.unwrap(); + assert_eq!(r.status(),200); + let raw=r.headers()["announce"].to_str().unwrap(); + let announce=raw.strip_prefix("base64:").map(|s|String::from_utf8(base64::engine::general_purpose::STANDARD.decode(s).unwrap()).unwrap()).unwrap_or_else(||raw.to_string()); + assert!(!announce.contains("Choose a server")); assert!(!announce.contains("Global header")); + if lang=="en" {assert!(announce.contains(english),"{id}/{ua}: {announce}");} + let body=r.text().await.unwrap(); + if let Some(previous)=&payload {assert_eq!(&body,previous,"Locale must not change machine payload {id}/{ua}");} else {payload=Some(body);} + } + } + } + let r=http.get(format!("{url}/active")).header(header::USER_AGENT,"Happ").send().await.unwrap(); + assert_eq!(r.headers()["announce"],"Global header"); + assert!(r.text().await.unwrap().contains("node.example.test")); + let r=http.get(format!("{url}/active")).header(header::USER_AGENT,"RuleClient").send().await.unwrap(); + assert_eq!(r.headers()["announce"],"Rule announcement"); + let r=http.get(format!("{url}/active")).header(header::USER_AGENT,"BlockedClient").send().await.unwrap(); + assert_eq!(r.status(),403); assert!(!r.headers().contains_key("announce")); + let r=http.get(format!("{url}/renewal")).header(header::USER_AGENT,"Happ").send().await.unwrap(); + assert_ne!(r.headers()["announce"],"Global header"); + renewed.store(true,std::sync::atomic::Ordering::SeqCst); + let r=http.get(format!("{url}/renewal")).header(header::USER_AGENT,"Happ").send().await.unwrap(); + assert_eq!(r.headers()["announce"],"Global header"); + assert!(r.text().await.unwrap().contains("node.example.test")); + let body=http.get(format!("{url}/custom?lang=en")).header(header::USER_AGENT,"Mozilla/5.0").send().await.unwrap().text().await.unwrap(); + assert!(body.contains("Custom <state>")); + assert!(!body.contains("")); + task.abort(); panel_task.abort(); +} diff --git a/crates/sub/src/main.rs b/crates/sub/src/main.rs index d1a4532..d962036 100644 --- a/crates/sub/src/main.rs +++ b/crates/sub/src/main.rs @@ -520,6 +520,22 @@ async fn qr_image(State(st): State, Path(short_id): Path) -> R .into_response()) } +/// Keep the core machine error contract; render only browser subscription errors. +fn subscription_load_error(error: Error, format: Format, lang: locale::Language) -> Response { + let response = error.into_response(); + if format != Format::WebPage { return response; } + let status = response.status(); + let title = lang.text("Не удалось открыть подписку", "Unable to open subscription"); + let message = if status == axum::http::StatusCode::NOT_FOUND { + lang.text("Проверьте ссылку: откройте свежую ссылку в боте или личном кабинете. Если это не помогло, обратитесь в поддержку.", "Check the link: open a fresh link from the bot or your account. If that does not help, contact support.") + } else { + lang.text("Повторите попытку позже. Если ошибка повторится, обратитесь в поддержку.", "Try again later. If the error persists, contact support.") + }; + // All content is fixed localized text: never reflect IDs, URLs or source errors. + let html = format!("{title}

{title}

{message}

", lang.code()); + (status, [(header::CONTENT_LANGUAGE, lang.code()), (header::CACHE_CONTROL, "no-store"), (header::VARY, "User-Agent, Accept-Language, Cookie")], Html(html)).into_response() +} + async fn subscription( State(st): State, Path(short_id): Path, @@ -532,7 +548,17 @@ async fn subscription( .and_then(|v| v.to_str().ok()) .unwrap_or(""); - let mut data = load(&st.source, &short_id).await?; + let mut data = match load(&st.source, &short_id).await { + Ok(data) => data, + Err(error) => { + // Use already available rules only: a source failure must not trigger + // more panel calls. Known VPN agents take precedence over Mozilla. + let rules = cached_rules(&st).await; + let format = matching_rule(&rules, &headers).and_then(rule_format) + .unwrap_or_else(|| detect_format(ua)); + return Ok(subscription_load_error(error, format, lang)); + } + }; let mut cfg = load_settings(&st).await; apply_external_settings(&mut cfg,&data.external_squad["settings"]); for host in &mut data.hosts {sn_sub::overrides::apply_host(host,&data.external_squad["hosts"]);} @@ -598,18 +624,26 @@ async fn subscription( let title=cfg.profile_title.clone().unwrap_or_else(||format!("{brand} - {}",data.username)); if let Some(rule)=matched{policy::apply_headers(&mut hdrs,&rule.response_headers,&data.username,&title);} let status=match format{Format::Block=>Some(403),Format::NotFound=>Some(404),Format::Unavailable=>Some(451),_=>None}; - if let Some(status)=status{return Ok((axum::http::StatusCode::from_u16(status).unwrap(),hdrs,"Доступ к подписке ограничен правилом ответа").into_response())} + if let Some(status)=status{hdrs.remove("announce");return Ok((axum::http::StatusCode::from_u16(status).unwrap(),hdrs,"Доступ к подписке ограничен правилом ответа").into_response())} if data.status != "active" || data.hosts.is_empty() || over_limit.is_some() || unsupported { let reason_key=if over_limit.is_some(){"devices"}else if unsupported{"unsupported"}else{match data.status.as_str(){"expired"=>"expired","limited"=>"limited","disabled"=>"disabled",_=>"no_hosts"}}; let fallback=if let Some((used,limit))=over_limit{cfg.remark_devices(&data,used as i64,limit as i64)}else if unsupported{"Приложение не передало HWID. Используйте приложение с поддержкой идентификатора устройства.".into()}else{cfg.remark_for(&data)}; let reasons:Vec=cfg.remark_lists.get(reason_key).map(|v|v.iter().map(|s|fill_placeholders_dev(s,&data,over_limit.map(|(u,l)|(u as i64,l as i64)))).collect()).unwrap_or_else(||vec![fallback]); let reason=reasons.join("\n"); + let fallback_template = if over_limit.is_some() { cfg.remark_devices.as_str() } else if unsupported { "Приложение не передало HWID. Используйте приложение с поддержкой идентификатора устройства." } else { match data.status.as_str() { "expired" => &cfg.remark_expired, "limited" => &cfg.remark_limited, "disabled" => &cfg.remark_disabled, _ => &cfg.remark_no_hosts } }; + let templates = cfg.remark_lists.get(reason_key).map(|v| v.iter().map(String::as_str).collect::>()).unwrap_or_else(|| vec![fallback_template]); + let notice = templates.iter().map(|s| fill_placeholders_dev(lang.default_text(s), &data, over_limit.map(|(u,l)| (u as i64,l as i64)))).collect::>().join("\n"); + // State reasons win over global, external-squad and rule announcements. + // Keep machine payloads unchanged; only the human-facing header is localized. + hdrs.remove("announce"); + let announcement = if notice.is_ascii() && notice.bytes().any(|b| b.is_ascii_control()) { + use base64::Engine as _; + format!("base64:{}", base64::engine::general_purpose::STANDARD.encode(¬ice)) + } else { header_text(¬ice) }; + if let Ok(value) = announcement.parse() { hdrs.insert("announce", value); } if format == Format::WebPage { let apps=load_apps(&st).await; let sub_url=format!("{}/{}",st.config.sub_public_url.trim_end_matches('/'),short_id); - let fallback_template = if over_limit.is_some() { cfg.remark_devices.as_str() } else if unsupported { "Приложение не передало HWID. Используйте приложение с поддержкой идентификатора устройства." } else { match data.status.as_str() { "expired" => &cfg.remark_expired, "limited" => &cfg.remark_limited, "disabled" => &cfg.remark_disabled, _ => &cfg.remark_no_hosts } }; - let templates = cfg.remark_lists.get(reason_key).map(|v| v.iter().map(String::as_str).collect::>()).unwrap_or_else(|| vec![fallback_template]); - let notice = templates.iter().map(|s| fill_placeholders_dev(lang.default_text(s), &data, over_limit.map(|(u,l)| (u as i64,l as i64)))).collect::>().join("\n"); return Ok((hdrs,Html(page::render_localized(&data,&brand,&sub_url,&apps,&cfg.page,¬ice,lang))).into_response()); } let stubs:Vec<_>=reasons.iter().map(|r|formats::stub_host(r)).collect(); diff --git a/crates/sub/src/page.rs b/crates/sub/src/page.rs index fb99f68..4567b7e 100644 --- a/crates/sub/src/page.rs +++ b/crates/sub/src/page.rs @@ -525,9 +525,14 @@ fn render_servers(d: &SubData, lang: Language) -> String { fn render_app(a: &App, sub_url: &str, lang: Language) -> String { let deeplink = a.deeplink.as_ref().filter(|v| !v.is_empty()).map(|dl| { - dl.replace("{{URL}}", &urlencode(sub_url)) - .replace("{URL}", &urlencode(sub_url)) - .replace("{url}", &urlencode(sub_url)) + let nested = if dl.starts_with("happ://add/") { + sub_url.to_owned() + } else { + urlencode(sub_url) + }; + dl.replace("{{URL}}", &nested) + .replace("{URL}", &nested) + .replace("{url}", &nested) }); let hint = if deeplink.is_some() { lang.text("Затем включите VPN в приложении.", "Then turn on VPN in the app.") @@ -787,7 +792,7 @@ mod tests { app.store_url = Some("https://store".into()); app.recommended = true; let html = render_app(&app, "https://sub.example/s/abc", Language::Ru); - assert!(html.contains("happ://add/https%3A%2F%2Fsub.example%2Fs%2Fabc")); + assert!(html.contains("happ://add/https://sub.example/s/abc")); assert!(html.contains("Установить Happ")); assert_eq!(html.matches("step-number").count(), 2); assert!(html.contains("Затем включите VPN в приложении")); @@ -883,4 +888,54 @@ mod tests { assert_eq!(humanize_days(180, Language::Ru), "осталось ~6 месяцев"); assert_eq!(humanize_days(31, Language::Ru), "осталось ~1 месяц"); } + + #[test] + fn sn_b07_happ_preserves_nested_https_and_html_escaping() { + for lang in [Language::Ru,Language::En] { + for placeholder in ["{url}","{URL}","{{URL}}"] { + for url in ["https://sub.example.test/s/fixture","https://sub.example.test/s/fixture?a=1&label=\"VPN\"&q='x'"] { + let mut a=app("Happ");a.deeplink=Some(format!("happ://add/{placeholder}")); + let html=render_app(&a,url,lang); + assert!(html.contains(&format!("href=\"{}\"",esc(&format!("happ://add/{url}")))),"Happ must keep the nested HTTPS URL: {html}"); + assert!(!html.contains("happ://add/https%3A")); + } + } + } + } + #[test] + fn sn_b07_other_schemes_keep_percent_encoding() { + let url="https://sub.example.test/s/fixture?a=1&b=2"; + for scheme in ["hiddify://import/","v2rayng://install-config?url=","happ://other/"] { + for placeholder in ["{url}","{URL}","{{URL}}"] { + let mut a=app("Other app");a.deeplink=Some(format!("{scheme}{placeholder}")); + for lang in [Language::Ru,Language::En] { + let html=render_app(&a,url,lang); + assert!(html.contains(&format!("href=\"{}\"",esc(&format!("{scheme}{}",urlencode(url)))))); + } + } + } + } + #[test] + fn sn_b07_whole_page_keeps_subscription_url_and_languages() { + let sub=SubData{ + external_squad:serde_json::json!({}),username:"fixture".into(),status:"active".into(), + expires_at:None,used:0,limit:None,device_limit:1,tariff:None, + hosts:vec![crate::formats::HostEntry { + remark:"Synthetic host".into(),address:"vpn.example.test".into(),port:443, + protocol:"vless".into(),network:"tcp".into(),security:"reality".into(), + sni:None,fingerprint:None,alpn:None,path:None,public_key:None, + short_id:None,method:None,service_name:None,server_key:None, + host_header:None,options:serde_json::json!({}), + uuid:"00000000-0000-0000-0000-000000000001".into(), + }], + }; + let mut apps=vec![app("Happ")];let settings=PageSettings::default(); + let url="https://sub.example.test/s/fixture?a=1&b=2"; + for a in &mut apps {a.deeplink=Some("happ://add/{{URL}}".into());} + for lang in [Language::Ru,Language::En] { + let html=render_localized(&sub,"QA",url,&apps,&settings,"",lang); + assert!(html.contains("happ://add/https://sub.example.test/s/fixture?a=1&b=2")); + assert!(!html.contains("happ://add/https%3A")); + } + } } diff --git a/crates/worker/src/autorenew.rs b/crates/worker/src/autorenew.rs index e27515b..5b01a1e 100644 --- a/crates/worker/src/autorenew.rs +++ b/crates/worker/src/autorenew.rs @@ -71,7 +71,7 @@ pub async fn tick(pool: &Pool, reg: &Registry, bot_token: Option<&str>) -> Resul if action_result != "charged" { if let (Some(token), Some(tg)) = (bot_token, telegram_id.as_deref()) { if let Ok(chat_id) = tg.parse::() { - notify(token, chat_id).await; + notify(token, chat_id, r.get("expires_at")).await; } } } @@ -172,9 +172,9 @@ async fn try_renew( } } -async fn notify(token: &str, chat_id: i64) { - let text = "⏳ Подписка заканчивается завтра.\n\n\ - Автоматически продлить не получилось — продлите вручную: /buy"; +async fn notify(token: &str, chat_id: i64, expires: chrono::DateTime) { + let text = format!("⏳ Подписка заканчивается {} UTC.\n\n\ + Автоматически продлить не получилось — продлите вручную: /buy", expires.format("%d.%m.%Y %H:%M")); let _ = reqwest::Client::new() .post(format!("https://api.telegram.org/bot{token}/sendMessage")) .json(&json!({ "chat_id": chat_id, "text": text })) diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index a4cc8a2..3fe4ae4 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -276,23 +276,15 @@ async fn notify_expiring(pool: &Pool, bot_token: &str, cfg: &Config) -> Result<( // Формулировка та же, что в кабинете: человеку приходит одно // и то же напоминание, откуда бы он ни смотрел. - let text = if days == 1 { - format!( - "⏳ Доступ заканчивается завтра, {}.\n\n\ - Продлите заранее — так не прервётся доступ в интернет: /buy", - expires.format("%d.%m") - ) - } else { - format!( - "📅 Доступ заканчивается через {days} {} — {}.\n\n\ - Продлите заранее — так не прервётся доступ в интернет: /buy", - склонение(days, "день", "дня", "дней"), - expires.format("%d.%m") - ) - }; + let text = format!( + "⏳ Доступ заканчивается {} UTC.\n\n\ + Продлите заранее — так не прервётся доступ в интернет: /buy", + expires.format("%d.%m.%Y %H:%M") + ); let template=sn_core::bot_config::text(&settings,"bot.notify_text",""); - let text=if template.is_empty() {text} else {template.replace("{days}",&days.to_string()).replace("{date}",&expires.format("%d.%m.%Y").to_string())}; + let remaining_days=((expires-Utc::now()).num_seconds().max(0)+86399)/86400; + let text=if template.is_empty() {text} else {template.replace("{days}",&remaining_days.to_string()).replace("{date}",&expires.format("%d.%m.%Y %H:%M UTC").to_string())}; let sent = send_message(bot_token, chat_id, &text).await; // Отметку ставим только при успешной отправке: иначе человек diff --git a/devtools/tests/checkout-promo.test.cjs b/devtools/tests/checkout-promo.test.cjs new file mode 100644 index 0000000..82f9c96 --- /dev/null +++ b/devtools/tests/checkout-promo.test.cjs @@ -0,0 +1,43 @@ +'use strict'; +const {test}=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'); +const ROOT=process.env.SN_SOURCE_ROOT||path.resolve(__dirname,'../..'); +const source=fs.readFileSync(path.join(ROOT,'web/app/app.js'),'utf8'); +function deferred(){let resolve,reject;const promise=new Promise((a,b)=>{resolve=a;reject=b});return {promise,resolve,reject};} +// DOM boundary only; actual buySheet, paymentOptions and translation code execute. +function harness(lang,opts={}){ + const nodes={},calls=[],buttons=[];let payGate; + function el(){return {textContent:'',disabled:false,isConnected:true,classList:{add(){},remove(){}},addEventListener(n,f){this['on'+n]=f},querySelectorAll(){return []}};} + for(const id of ['#promo','#applyPromo','#quote','#payActions','#payError','.dialog-body'])nodes[id]=el(); + const periods=[3,30,60].map(n=>Object.assign(el(),{value:String(n)})); + Object.defineProperty(nodes['#payActions'],'innerHTML',{set(html){this.markup=html;buttons.length=0;for(const m of html.matchAll(/]*data-pay="([^"]+)"[^>]*)>([^]*?)<\/button>/g))buttons.push(Object.assign(el(),{dataset:{pay:m[2]},textContent:m[3],disabled:/\bdisabled\b/.test(m[1])}));},get(){return this.markup}}); + nodes['#payActions'].querySelectorAll=()=>buttons; + const dialog={open:true,querySelector:s=>nodes[s]||null,querySelectorAll:s=>s==='input[name="period"]'?periods:s==='input,select,button'?[...periods,...buttons,nodes['#promo'],nodes['#applyPromo']]:[],close(){this.open=false}}; + const prices=[{days:3,currency:'USD',amount_minor:0},{days:30,currency:'USD',amount_minor:100},{days:60,currency:'USD',amount_minor:300},{days:30,currency:'XTR',amount_minor:20},{days:60,currency:'XTR',amount_minor:60}]; + const shop={tariffs:[{id:1,title:'Synthetic',description:'Test',prices}],methods_by_currency:{USD:[{id:'manual',title:'Manual'}],XTR:opts.stars===false?[]:[{id:'stars',title:'Stars'}]}}; + let quoteHandler=async b=>{ + const amount=prices.find(p=>p.days===b.days&&p.currency===b.currency).amount_minor; + if(b.promo==='INVALID'||b.promo==='FXERR'&&b.currency==='XTR')throw Error('Invalid promo'); + return {amount_minor:b.promo==='ZERO'?0:b.promo==='HALF'?amount/2:b.promo==='FIXED'||b.promo==='FXERR'?b.currency==='USD'?0:amount:b.promo==='STARZERO'?b.currency==='XTR'?0:amount:amount,days:b.days+(b.promo==='BONUS'?5:0)}; + }; + const c=vm.createContext({URL,URLSearchParams,location:{search:'?lang='+lang},localStorage:{getItem:()=>null},window:{},navigator:{language:lang},document:{documentElement:{},addEventListener(){}},dialog,shop,currency:'USD',tg:null,activePlan:null,me:null,pendingPayment:null,esc:String,duration:String,money:(n,cur)=>`${n} ${cur}`,showDialog(){dialog.open=true},navigate(){},notice(){},safeURL:()=>false,api:async()=>({}),post:async(url,body)=>{calls.push({url,body});if(url==='/quote')return quoteHandler(body);if(url==='/pay'){payGate=deferred();return payGate.promise;}throw Error(url)}}); + for(const file of ['customer-en.js','customer-i18n.js'])vm.runInContext(fs.readFileSync(path.join(ROOT,'web/app',file),'utf8'),c); + vm.runInContext(source.slice(source.indexOf('function paymentOptions('),source.indexOf('\nconst money =')),c); + vm.runInContext(source.slice(source.indexOf('function buySheet('),source.indexOf('\nfunction openInvoice(')),c); + vm.runInContext('buySheet(1,30)',c); + return {c,nodes,calls,buttons,dialog,free:lang==='ru'?'Активировать бесплатно':'Activate for free',button:id=>buttons.find(b=>b.dataset.pay===id),setCode(code){nodes['#promo'].oninput({target:{value:code}})},apply(){return nodes['#applyPromo'].onclick()},async promo(code){this.setCode(code);await this.apply()},period(n){periods.find(p=>p.value===String(n)).onchange({target:{value:String(n)}})},setQuoteHandler(fn){quoteHandler=fn},resolvePay(){payGate.resolve({free:true})}}; +} +for(const lang of ['ru','en']){ + test(`${lang}: zero quote changes action without changing provider`,async()=>{const h=harness(lang);await h.promo('ZERO');assert.equal(h.button('manual').textContent,h.free);assert.equal(h.button('stars').textContent,h.free);assert(!h.button('free'));}); + test(`${lang}: amounts are evaluated separately per currency`,async()=>{const h=harness(lang);await h.promo('FIXED');assert.equal(h.button('manual').textContent,h.free);assert.notEqual(h.button('stars').textContent,h.free);await h.promo('STARZERO');assert.notEqual(h.button('manual').textContent,h.free);assert.equal(h.button('stars').textContent,h.free);}); + test(`${lang}: positive discount and bonus days keep paid action`,async()=>{const h=harness(lang);for(const code of ['HALF','BONUS']){await h.promo(code);assert.notEqual(h.button('manual').textContent,h.free);assert.notEqual(h.button('stars').textContent,h.free);}}); + test(`${lang}: invalid alternative quote stays disabled`,async()=>{const h=harness(lang);await h.promo('FXERR');assert.equal(h.button('manual').textContent,h.free);assert(h.button('stars').disabled);assert.notEqual(h.button('stars').textContent,h.free);}); + test(`${lang}: changing code clears free action and invalid code blocks checkout`,async()=>{const h=harness(lang);await h.promo('ZERO');h.setCode('INVALID');assert.notEqual(h.button('manual').textContent,h.free);await h.apply();await h.button('manual').onclick();assert.equal(h.calls.filter(x=>x.url==='/pay').length,0);}); + test(`${lang}: changing period clears quote; original free tariff keeps free provider`,async()=>{const h=harness(lang);await h.promo('ZERO');h.period(60);assert.notEqual(h.button('manual').textContent,h.free);h.period(3);assert(h.button('free'));assert(!h.button('manual'));}); + test(`${lang}: stale quote after code change cannot restore free label`,async()=>{const h=harness(lang,{stars:false}),gate=deferred();h.setQuoteHandler(()=>gate.promise);h.setCode('ZERO');const waiting=h.apply();h.setCode('HALF');gate.resolve({amount_minor:0,days:30});await waiting;assert.notEqual(h.button('manual').textContent,h.free);}); + test(`${lang}: stale quote after period change cannot restore free label`,async()=>{const h=harness(lang,{stars:false}),gate=deferred();h.setQuoteHandler(()=>gate.promise);h.setCode('ZERO');const waiting=h.apply();h.period(60);gate.resolve({amount_minor:0,days:30});await waiting;assert.notEqual(h.button('manual').textContent,h.free);}); + test(`${lang}: double click dispatches one checkout using original provider`,async()=>{const h=harness(lang);await h.promo('ZERO');const b=h.button('manual'),first=b.onclick();const second=b.onclick();assert.equal(h.calls.filter(x=>x.url==='/pay').length,1);assert.equal(h.calls.find(x=>x.url==='/pay').body.provider,'manual');h.resolvePay();await Promise.all([first,second]);}); + test(`${lang}: Stars activation keeps Stars currency and provider`,async()=>{const h=harness(lang);await h.promo('ZERO');const request=h.button('stars').onclick();const call=h.calls.find(x=>x.url==='/pay');assert.equal(call.body.provider,'stars');assert.equal(call.body.currency,'XTR');h.resolvePay();await request;}); + test(`${lang}: stale secondary Stars quote cannot restore a cleared quote`,async()=>{const h=harness(lang),gate=deferred(),started=deferred();h.setQuoteHandler(b=>{if(b.currency==='XTR'){started.resolve();return gate.promise;}return Promise.resolve({amount_minor:0,days:30});});h.setCode('ZERO');const waiting=h.apply();await started.promise;h.period(60);gate.resolve({amount_minor:0,days:30});await waiting;assert.notEqual(h.button('manual').textContent,h.free);assert.notEqual(h.button('stars').textContent,h.free);}); + test(`${lang}: positive quote preserves exact paid labels and Stars amount`,async()=>{const h=harness(lang);await h.promo('HALF');const fork=source.includes('Оформить счёт ·');assert.equal(h.button('manual').textContent,lang==='ru'?(fork?'Оформить счёт · Manual':'Оплатить · Manual'):(fork?'Create invoice · Manual':'Pay · Manual'));assert.equal(h.button('stars').textContent,lang==='ru'?'Оплатить 10 XTR':'Pay 10 XTR');}); + +} diff --git a/devtools/tests/customer-payment-return.test.cjs b/devtools/tests/customer-payment-return.test.cjs new file mode 100644 index 0000000..128136d --- /dev/null +++ b/devtools/tests/customer-payment-return.test.cjs @@ -0,0 +1,156 @@ +'use strict'; +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const root = path.resolve(__dirname, '../..'); +const app = fs.readFileSync(path.join(root, 'web/app/app.js'), 'utf8'); + +// Minimal DOM boundary. Routing, login, storefront and payment rendering +// execute their production functions; all API responses are synthetic. +function element() { + let markup = ''; + const children = new Map(); + return { + hidden:false, open:false, value:'', disabled:false, + set innerHTML(html) { + markup = html; + children.clear(); + for (const match of html.matchAll(/\bid="([^"]+)"/g)) children.set('#' + match[1], element()); + }, + get innerHTML() { return markup; }, + querySelector(selector) { + if (selector === 'button') { + if (!children.has(selector)) children.set(selector, element()); + } + return children.get(selector) || null; + }, + querySelectorAll() { return []; }, + insertAdjacentHTML(_position, html) { markup += html; }, + addEventListener(event, handler) { this['on' + event] = handler; }, + setAttribute() {}, + showModal() { this.open = true; }, + close() { this.open = false; }, + }; +} + +function screen(hash, authorized = false) { + const view = element(), dialog = element(), requests = []; + const desktopNav = element(), header = element(); + const config = { + brand:'Test service', headline:'Test storefront', description:'Choose your access', + tariffs_heading:'Plans', faq_heading:'Help', registration_enabled:true, + language_content_ready:true, shop_enabled:true, steps:[], faq:[], docs_links:[], + bot:'', support_url:'', logo:'', logo_dark:'', referral_enabled:false, + }; + const account = {username:'fixture', tariff:'Test plan', status:'active', code_saved:true}; + const api = async (url, options = {}) => { + const method = options.method || 'GET'; + requests.push({method, url}); + if (method === 'GET' && url === '/config') return config; + if (method === 'GET' && url === '/auth/session') { + if (!authorized) throw Object.assign(new Error('No session'), {status:401}); + return {csrf:'fixture-csrf', code_saved:true}; + } + if (method === 'GET' && url === '/catalog') return {currency:'RUB', tariffs:[]}; + if (method === 'POST' && url === '/auth/login') { + assert.equal(options.body.code, 'fixture-access-code'); + authorized = true; + return {csrf:'fixture-csrf', code_saved:true}; + } + assert(authorized, 'Private customer data requested before login'); + if (method === 'GET' && url === '/me') return account; + if (method === 'GET' && url === '/payments?offset=0') return { + items:[{id:101, title:'Existing canceled payment', status:'canceled', + amount_minor:17500, currency:'RUB', created_at:'2026-10-09T10:00:00Z', url:null}], + has_more:false, + }; + throw new Error(`Unexpected request: ${method} ${url}`); + }; + const context = vm.createContext({ + URL, URLSearchParams, location:{hash, search:''}, localStorage:{getItem:()=>null}, + window:{scrollTo(){}}, navigator:{language:'ru'}, + document:{body:{className:''}, documentElement:{}, addEventListener(){}, + querySelector:selector => selector === '#desktopNav' ? desktopNav : selector === '.app-header' ? header : null}, + root:view, dialog, tabs:element(), me:null, config:{}, shop:null, currency:'', + section:'home', generation:0, tg:null, ICON:{}, + api, post:(url, body = {}) => api(url, {method:'POST', body}), + esc:value => String(value ?? ''), + money:value => `${value / 100} RUB`, date:value => value, + statusLabel:{canceled:'Canceled'}, + errorBox:error => { throw error; }, + }); + for (const file of ['customer-en.js', 'customer-i18n.js', 'customer-ui.js']) { + vm.runInContext(fs.readFileSync(path.join(root, 'web/app', file), 'utf8'), context); + } + const safeURL = app.match(/const safeURL = \(s\) => \{[^]*?\n\};/); + assert(safeURL, 'Production URL validator is available'); + vm.runInContext(safeURL[0], context); + for (const name of ['showDialog', 'navigate', 'draw', 'drawPayments']) { + const declaration = app.match(new RegExp(`(?:async )?function ${name}\\([^]*?\\n}`)); + assert(declaration, `Production function ${name} is available`); + vm.runInContext(declaration[0], context); + } + // Theme/header/navigation decoration is unrelated to authentication or routing. + vm.runInContext('applyCustomerBrand=()=>{}; mountCustomerHeader=()=>{}; drawNav=()=>{};', context); + context.syncBack = () => {}; + return { + view, dialog, requests, context, + async boot() { + await vm.runInContext('cabinetBoot()', context); + await new Promise(resolve => setImmediate(resolve)); + }, + async login() { + const form = dialog.querySelector('#codeLogin'); + assert(form, 'Return without a session must offer code login'); + dialog.querySelector('#loginCode').value = 'fixture-access-code'; + await form.onsubmit({preventDefault(){}, target:form}); + await new Promise(resolve => setImmediate(resolve)); + }, + }; +} + +test('ordinary unauthenticated visit keeps the storefront without forcing login', async () => { + const s = screen(''); + await s.boot(); + assert(s.view.querySelector('#tariffs')); + assert.equal(s.dialog.open, false); + assert.equal(s.context.location.hash, ''); + assert.equal(s.context.document.body.className, 'public-site'); + assert.deepEqual(s.requests.filter(r => r.method !== 'GET'), []); +}); + +test('explicit login link still opens the code form', async () => { + const s = screen('#login'); + await s.boot(); + assert.equal(s.dialog.open, true); + assert(s.dialog.querySelector('#codeLogin')); + assert.equal(s.context.location.hash, '#login'); + assert(!s.requests.some(r => r.url === '/me')); +}); + +test('payment return with a session renders existing payment history without creating an invoice', async () => { + const s = screen('#payments', true); + await s.boot(); + assert.equal(s.dialog.open, false); + assert.equal(s.context.section, 'payments'); + assert.match(s.view.querySelector('#history').innerHTML, /Existing canceled payment/); + assert.equal(s.context.location.hash, '#payments'); + assert.deepEqual(s.requests.filter(r => r.method !== 'GET'), []); +}); + +test('payment return without a session preserves its destination through code login', async () => { + const s = screen('#payments'); + await s.boot(); + assert.equal(s.dialog.open, true, 'Payment return must open login instead of leaving the storefront'); + assert(s.dialog.querySelector('#codeLogin')); + assert.equal(s.context.location.hash, '#payments'); + assert(!s.requests.some(r => r.url === '/me' || r.url.startsWith('/payments'))); + await s.login(); + assert.equal(s.dialog.open, false); + assert.equal(s.context.section, 'payments'); + assert.equal(s.context.location.hash, '#payments'); + assert.match(s.view.querySelector('#history').innerHTML, /Existing canceled payment/); + assert.deepEqual(s.requests.filter(r => r.method !== 'GET'), [{method:'POST', url:'/auth/login'}]); +}); diff --git a/devtools/tests/web-payment-errors.test.cjs b/devtools/tests/web-payment-errors.test.cjs new file mode 100644 index 0000000..c4e2256 --- /dev/null +++ b/devtools/tests/web-payment-errors.test.cjs @@ -0,0 +1,34 @@ +'use strict'; +const {test}=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'); +const ROOT=process.env.SN_SOURCE_ROOT||path.resolve(__dirname,'../..'); +const source=fs.readFileSync(path.join(ROOT,'web/app/app.js'),'utf8'); +function deferred(){let resolve,reject;const promise=new Promise((a,b)=>{resolve=a;reject=b});return {promise,resolve,reject};} +// DOM boundary only; actual buySheet, paymentOptions and translation code execute. +function harness(lang,opts={}){ + const nodes={},calls=[],buttons=[];let payGate; + function el(){return {textContent:'',disabled:false,isConnected:true,classList:{add(){},remove(){}},addEventListener(n,f){this['on'+n]=f},querySelectorAll(){return []}};} + for(const id of ['#promo','#applyPromo','#quote','#payActions','#payError','.dialog-body'])nodes[id]=el(); + const periods=[3,30,60].map(n=>Object.assign(el(),{value:String(n)})); + Object.defineProperty(nodes['#payActions'],'innerHTML',{set(html){this.markup=html;buttons.length=0;for(const m of html.matchAll(/]*data-pay="([^"]+)"[^>]*)>([^]*?)<\/button>/g))buttons.push(Object.assign(el(),{dataset:{pay:m[2]},textContent:m[3],disabled:/\bdisabled\b/.test(m[1])}));},get(){return this.markup}}); + nodes['#payActions'].querySelectorAll=()=>buttons; + const dialog={open:true,querySelector:s=>nodes[s]||null,querySelectorAll:s=>s==='input[name="period"]'?periods:s==='input,select,button'?[...periods,...buttons,nodes['#promo'],nodes['#applyPromo']]:[],close(){this.open=false}}; + const prices=[{days:3,currency:'USD',amount_minor:0},{days:30,currency:'USD',amount_minor:100},{days:60,currency:'USD',amount_minor:300},{days:30,currency:'XTR',amount_minor:20},{days:60,currency:'XTR',amount_minor:60}]; + const shop={tariffs:[{id:1,title:'Synthetic',description:'Test',prices}],methods_by_currency:{USD:[{id:'manual',title:'Manual'}],XTR:opts.stars===false?[]:[{id:'stars',title:'Stars'}]}}; + let quoteHandler=async b=>{ + const amount=prices.find(p=>p.days===b.days&&p.currency===b.currency).amount_minor; + if(b.promo==='INVALID'||b.promo==='FXERR'&&b.currency==='XTR')throw Error('Invalid promo'); + return {amount_minor:b.promo==='ZERO'?0:b.promo==='HALF'?amount/2:b.promo==='FIXED'||b.promo==='FXERR'?b.currency==='USD'?0:amount:b.promo==='STARZERO'?b.currency==='XTR'?0:amount:amount,days:b.days+(b.promo==='BONUS'?5:0)}; + }; + const c=vm.createContext({URL,URLSearchParams,location:{search:'?lang='+lang},localStorage:{getItem:()=>null},window:{},navigator:{language:lang},document:{documentElement:{},addEventListener(){}},dialog,shop,currency:'USD',tg:null,activePlan:null,me:null,pendingPayment:null,esc:String,duration:String,money:(n,cur)=>`${n} ${cur}`,showDialog(){dialog.open=true},navigate(){},notice(){},safeURL:()=>false,api:async()=>({}),post:async(url,body)=>{calls.push({url,body});if(url==='/quote')return quoteHandler(body);if(url==='/pay'){payGate=deferred();return payGate.promise;}throw Error(url)}}); + for(const file of ['customer-en.js','customer-i18n.js'])vm.runInContext(fs.readFileSync(path.join(ROOT,'web/app',file),'utf8'),c); + vm.runInContext(source.slice(source.indexOf('function paymentOptions('),source.indexOf('\nconst money =')),c); + vm.runInContext(source.slice(source.indexOf('function buySheet('),source.indexOf('\nfunction openInvoice(')),c); + vm.runInContext('buySheet(1,30)',c); + return {c,nodes,calls,buttons,dialog,free:lang==='ru'?'Активировать бесплатно':'Activate for free',button:id=>buttons.find(b=>b.dataset.pay===id),setCode(code){nodes['#promo'].oninput({target:{value:code}})},apply(){return nodes['#applyPromo'].onclick()},async promo(code){this.setCode(code);await this.apply()},period(n){periods.find(p=>p.value===String(n)).onchange({target:{value:String(n)}})},setQuoteHandler(fn){quoteHandler=fn},resolvePay(){payGate.resolve({free:true})}}; +} +for(const lang of ['ru','en']) for(const detached of [false,true]) test(`${lang} B15 masks provider errors; detached=${detached}`,async()=>{ + const h=harness(lang),notices=[];h.c.notice=m=>notices.push(m);h.c.post=async()=>{throw Error('provider API secret=example_123 currency merchant key')}; + if(detached)h.nodes['.dialog-body'].isConnected=false; + await h.button('manual').onclick();const text=detached?notices.join(' '):h.nodes['#payError'].textContent; + assert.doesNotMatch(text,/example_123|merchant|API|secret|currency/);assert.match(text,lang==='ru'?/историю платежей/:/payment history/);assert.match(text,lang==='ru'?/поддержку перед новой оплатой/:/support before making another payment/); +}); diff --git a/devtools/tests/web-regressions.test.cjs b/devtools/tests/web-regressions.test.cjs new file mode 100644 index 0000000..dcda592 --- /dev/null +++ b/devtools/tests/web-regressions.test.cjs @@ -0,0 +1,58 @@ +const {test}=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'); +const dir=process.env.SN_SOURCE_ROOT||path.resolve(__dirname,'../..'); +const app=fs.readFileSync(path.join(dir,'web/app/app.js'),'utf8'); +function harness(lang){ + const nodes={}; const element=()=>({value:'',textContent:'',disabled:false,addEventListener(k,f){this['on'+k]=f},querySelectorAll(){return []},insertAdjacentHTML(_,h){this.innerHTML=(this.innerHTML||'')+h}}); + const root={markup:'',set innerHTML(h){this.markup=h;for(const k of Object.keys(nodes))delete nodes[k];for(const [,id] of h.matchAll(/id="([^"]+)"/g))nodes['#'+id]=element();if(nodes['#message'])nodes['#message'].value=(h.match(/]*>([^]*?)<\/textarea>/)||[])[1]||''},get innerHTML(){return this.markup},querySelector(s){return nodes[s]||null},querySelectorAll(){return []},insertAdjacentHTML(_,h){this.innerHTML=h+this.markup}}; + const c=vm.createContext({URL,URLSearchParams,location:{search:'?lang='+lang},window:{},navigator:{language:lang},document:{documentElement:{},addEventListener(){}},localStorage:{getItem:()=>null},root,generation:1,currentTicket:null,drafts:new Map(),config:{tickets_enabled:true,docs_links:[]},statusLabel:{active:'Active',expired:'Expired'},ticketStatus:{},esc:v=>String(v??'').replaceAll('&','&').replaceAll('<','<').replaceAll('"','"'),safeURL:v=>{try {const u=new URL(v);return ['https:','http:'].includes(u.protocol)?u.href:null}catch{return null}},richText:()=>'',date:String,money:String,syncBack(){},navigate(){c.generation++;c.currentTicket=null;root.innerHTML='new screen'},notice(){c.notices++},notices:0,errorBox(e){root.innerHTML='
'+e.message+'
'},api:async()=>({items:[]}),post:async()=>({id:2}),openURL(){},openInvoice(){},checkPayment(){},uiIcon:()=>'',bytes:String,canChoosePlan:()=>true,drawDevices(){},showAccess(){},mountCustomerHeader(){}}); + for(const f of ['customer-en.js','customer-i18n.js'])vm.runInContext(fs.readFileSync(path.join(dir,'web/app',f),'utf8'),c); + vm.runInContext(app.slice(app.indexOf('async function drawPayments('),app.indexOf('\ntg?.ready')),c); + return {c,root,nodes}; +} +function gate(){let resolve,reject;const promise=new Promise((a,b)=>{resolve=a;reject=b});return {promise,resolve,reject}} +for(const lang of ['ru','en']){ + test(`${lang} B16 document titles are escaped, unsafe URLs omitted and empty list works`,async()=>{ + const h=harness(lang);h.c.config.docs_links=[{title:'Правила