Reject malformed notification feeds before state changes
This commit is contained in:
@@ -853,3 +853,10 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
|
|||||||
- Götürü bedel ve teklif mektubunda ortak bidAmount geçersiz, sıfıra yuvarlanan ve hesaplama sınırını aşan tutarı reddeder. Eksik fiyat artık genel toplamda sıfır gibi gösterilmez; Hesaplanamadı açıklaması görünür.
|
- Götürü bedel ve teklif mektubunda ortak bidAmount geçersiz, sıfıra yuvarlanan ve hesaplama sınırını aşan tutarı reddeder. Eksik fiyat artık genel toplamda sıfır gibi gösterilmez; Hesaplanamadı açıklaması görünür.
|
||||||
- 193 otomatik test ve derleme geçti. Son test genişletmesinden sonra ilgili 10 test yeniden geçti. Yüz adet 3 × 0,335 satırının her biri 1,01; ortak toplam, HTML ve gerçek XLSX dosyasından geri okunan toplam 101,00 olarak doğrulandı. Toplam taşması ve tutarsız toplam üretimi reddedildi.
|
- 193 otomatik test ve derleme geçti. Son test genişletmesinden sonra ilgili 10 test yeniden geçti. Yüz adet 3 × 0,335 satırının her biri 1,01; ortak toplam, HTML ve gerçek XLSX dosyasından geri okunan toplam 101,00 olarak doğrulandı. Toplam taşması ve tutarsız toplam üretimi reddedildi.
|
||||||
- Bu kanıt HTML/XLSX hesaplamasını kapsar; tarayıcı PDF indirme, çok sayfalı görsel yerleşim, resmi ihale belgelerinin doğruluğu veya firma bilgilerinin gerçekliğini kanıtlamaz. Firma alanlarında bu tur yeni doğrulama uygulanmadı.
|
- Bu kanıt HTML/XLSX hesaplamasını kapsar; tarayıcı PDF indirme, çok sayfalı görsel yerleşim, resmi ihale belgelerinin doğruluğu veya firma bilgilerinin gerçekliğini kanıtlamaz. Firma alanlarında bu tur yeni doğrulama uygulanmadı.
|
||||||
|
|
||||||
|
### Bildirim kaynağı hatasını boş sonuçtan ayırma — 6 Eylül 2026
|
||||||
|
- Bildirim turu daha önce eksik tenders alanını boş liste kabul ederek yeni aboneliğin başlangıç geçmişini oluşturabiliyordu. Şimdi kaynak şekli tüm abonelik döngüsünden önce doğrulanır: başarısız yanıt, eksik/dizi olmayan liste, kimliksiz/başlıksız kayıt, tekrar eden kayıt kimliği veya bozuk items yapısı turu hata ile durdurur; baseline/outbox işlemi başlamaz.
|
||||||
|
- Geçerli tenders:[] yanıtı gerçekten boş liste olarak işlenmeye devam eder. Hatalı tur sonrası processing kilidinin bırakılması ve sonraki geçerli turun çalışması test edildi.
|
||||||
|
- E-posta kriter açıklaması ortak escapeHtml ile kaçırılır; kullanıcı kriterindeki HTML bağlantısı/etiketi e-posta gövdesine işaretleme olarak giremez. Konu satırı düz metnini korur.
|
||||||
|
- 195 otomatik test geçti. Yalnız sunucu ve test kodu değiştiğinden frontend derlemesi tekrarlanmadı. Testler dış e-posta göndermez.
|
||||||
|
- Bu kontrol beslemenin yapısını doğrular; şekli geçerli fakat eksik/eski kaynakların tamlık ve güncelliğini kanıtlamaz. Bekleyen gönderimin ihalesi sonradan iptal olduğunda yeniden değerlendirme, gerçek sağlayıcı teslimi ve diğer plan kabulleri açık.
|
||||||
|
|||||||
+1
-1
@@ -76,6 +76,6 @@ export function tenderDigestHtml({ name, tenders }) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s) {
|
export function escapeHtml(s) {
|
||||||
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-3
@@ -10,12 +10,28 @@ import {deadlineInfo} from '../src/services/tenderCalendar.js';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { list, markBaseline, reserveDelivery, acceptDelivery, failDelivery, available } from './notify-store.mjs';
|
import { list, markBaseline, reserveDelivery, acceptDelivery, failDelivery, available } from './notify-store.mjs';
|
||||||
import { emailConfigured, emailFrom, sendEmail, tenderDigestHtml } from './email.mjs';
|
import { emailConfigured, emailFrom, sendEmail, tenderDigestHtml, escapeHtml } from './email.mjs';
|
||||||
import { telegramConfigured, sendTelegram, tgKacir } from './telegram.mjs';
|
import { telegramConfigured, sendTelegram, tgKacir } from './telegram.mjs';
|
||||||
import { getData } from './user-store.mjs';
|
import { getData } from './user-store.mjs';
|
||||||
|
|
||||||
const norm = (v) => String(v || '').trim().toLocaleUpperCase('tr-TR');
|
const norm = (v) => String(v || '').trim().toLocaleUpperCase('tr-TR');
|
||||||
|
|
||||||
|
/** Reject an unreadable feed before baseline or outbox mutations. */
|
||||||
|
export function notificationSource(payload) {
|
||||||
|
const invalid = () => { throw new Error('İhale bildirim kaynağı geçersiz; abonelik geçmişi değiştirilmedi.'); };
|
||||||
|
if (!payload || typeof payload !== 'object' || payload.success === false || !Array.isArray(payload.tenders)) invalid();
|
||||||
|
const ids = new Set();
|
||||||
|
for (const tender of payload.tenders) {
|
||||||
|
if (!tender || typeof tender !== 'object' || Array.isArray(tender)
|
||||||
|
|| typeof tender.id !== 'string' || !tender.id.trim() || ids.has(tender.id)
|
||||||
|
|| typeof tender.baslik !== 'string' || !tender.baslik.trim()) invalid();
|
||||||
|
ids.add(tender.id);
|
||||||
|
if (tender.items != null && (!Array.isArray(tender.items) || tender.items.some(item =>
|
||||||
|
!item || typeof item !== 'object' || Array.isArray(item) || (item.ad != null && typeof item.ad !== 'string')))) invalid();
|
||||||
|
}
|
||||||
|
return payload.tenders;
|
||||||
|
}
|
||||||
|
|
||||||
/** Bir ihale, aboneliğin kriterlerine uyuyor mu? */
|
/** Bir ihale, aboneliğin kriterlerine uyuyor mu? */
|
||||||
export function eslesiyorMu(t, a) {
|
export function eslesiyorMu(t, a) {
|
||||||
if (a.sehir && norm(t.sehir) !== norm(a.sehir)) return false;
|
if (a.sehir && norm(t.sehir) !== norm(a.sehir)) return false;
|
||||||
@@ -65,7 +81,7 @@ export async function bildirimleriIsle(tenderGetir, { zorla = false } = {}, depe
|
|||||||
if (!abonelikler.length) return { abonelik: 0 };
|
if (!abonelikler.length) return { abonelik: 0 };
|
||||||
|
|
||||||
const veri = await tenderGetir();
|
const veri = await tenderGetir();
|
||||||
const tumu = (veri.tenders || []).filter(t=>bildirilebilir(t,now()));
|
const tumu = notificationSource(veri).filter(t=>bildirilebilir(t,now()));
|
||||||
const requested=Number(process.env.NOTIFY_MAX || 25);
|
const requested=Number(process.env.NOTIFY_MAX || 25);
|
||||||
const maxAdet=Number.isInteger(requested)&&requested>0?Math.min(requested,200):25;
|
const maxAdet=Number.isInteger(requested)&&requested>0?Math.min(requested,200):25;
|
||||||
|
|
||||||
@@ -98,7 +114,7 @@ export async function bildirimleriIsle(tenderGetir, { zorla = false } = {}, depe
|
|||||||
|
|
||||||
const html = tenderDigestHtml({ name: a.ad || a.email, tenders: gonderilecek.slice(0, maxAdet) })
|
const html = tenderDigestHtml({ name: a.ad || a.email, tenders: gonderilecek.slice(0, maxAdet) })
|
||||||
+ `<p style="font-family:Arial,sans-serif;font-size:11px;color:#8b93a4;max-width:640px;margin:12px auto 0">
|
+ `<p style="font-family:Arial,sans-serif;font-size:11px;color:#8b93a4;max-width:640px;margin:12px auto 0">
|
||||||
Kriteriniz: ${kriterMetni} — toplam ${gonderilecek.length} ihale.
|
Kriteriniz: ${escapeHtml(kriterMetni)} — toplam ${gonderilecek.length} ihale.
|
||||||
Bu bildirimi uygulamadaki Mobil PWA → E-posta Bildirimi bölümünden kapatabilirsiniz.
|
Bu bildirimi uygulamadaki Mobil PWA → E-posta Bildirimi bölümünden kapatabilirsiniz.
|
||||||
</p>`;
|
</p>`;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {bildirimleriIsle,bildirilebilir} from '../server/notifier.mjs';
|
import {bildirimleriIsle,bildirilebilir} from '../server/notifier.mjs';
|
||||||
const future={tarih:'10.09.2026',saat:'10:00',durum:'Açık',baslik:'Laboratuvar'};
|
const future={id:'future-tender',tarih:'10.09.2026',saat:'10:00',durum:'Açık',baslik:'Laboratuvar'};
|
||||||
const now=()=>new Date('2026-09-06T10:00:00Z');
|
const now=()=>new Date('2026-09-06T10:00:00Z');
|
||||||
test('notification eligibility excludes expired cancelled and unknown deadlines',()=>{
|
test('notification eligibility excludes expired cancelled and unknown deadlines',()=>{
|
||||||
assert.equal(bildirilebilir(future,now()),true);
|
assert.equal(bildirilebilir(future,now()),true);
|
||||||
@@ -24,3 +24,34 @@ test('baseline is distinct from delivery and overlapping turns do not send twice
|
|||||||
assert.equal((await bildirimleriIsle(()=>assert.fail(),{},dependencies)).atlandi,'önceki tur sürüyor');
|
assert.equal((await bildirimleriIsle(()=>assert.fail(),{},dependencies)).atlandi,'önceki tur sürüyor');
|
||||||
release();await first;assert.equal(baseline,1);
|
release();await first;assert.equal(baseline,1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('unreadable source never establishes baseline or touches delivery state, and next turn recovers',async()=>{
|
||||||
|
let baseline=0;
|
||||||
|
const deps={now,configured:()=>true,readSubscriptions:()=>[{id:1,initialized:false}],
|
||||||
|
saveBaseline:()=>baseline++,reserve:()=>assert.fail('invalid source must not reserve'),
|
||||||
|
accepted:()=>assert.fail('invalid source must not acknowledge'),deliverEmail:()=>assert.fail('must not deliver')};
|
||||||
|
for(const payload of [null,{}, {success:false,tenders:[]},{tenders:{}},{tenders:[null]},
|
||||||
|
{tenders:[{...future,id:''}]},{tenders:[{...future,baslik:''}]},
|
||||||
|
{tenders:[future,future]},{tenders:[{...future,items:{}}]},
|
||||||
|
{tenders:[{...future,items:[null]}]}]) {
|
||||||
|
await assert.rejects(bildirimleriIsle(async()=>payload,{},deps),/kaynağı geçersiz/);
|
||||||
|
assert.equal(baseline,0);
|
||||||
|
}
|
||||||
|
await bildirimleriIsle(async()=>({success:true,tenders:[]}),{},deps);
|
||||||
|
assert.equal(baseline,1,'a valid empty feed is distinct from an unreadable feed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subscription criteria remain plain text in outgoing digest HTML',async()=>{
|
||||||
|
let message;
|
||||||
|
const criteria='<a href="https://example.test">Laboratuvar</a>';
|
||||||
|
const deps={now,configured:()=>true,telegramReady:()=>false,
|
||||||
|
readSubscriptions:()=>[{id:1,email:'qa@example.test',initialized:true,anahtar:criteria}],
|
||||||
|
reserve:(id,payload,tenderIds)=>({id:'escaped-test',payload,tenderIds}),accepted:()=>{},failed:()=>{},
|
||||||
|
deliverEmail:async payload=>{message=payload;return {id:'local-test'};}};
|
||||||
|
await bildirimleriIsle(async()=>({tenders:[{...future,baslik:criteria}]}),{},deps);
|
||||||
|
assert.ok(message);
|
||||||
|
assert.ok(message.html.includes('Kriteriniz: Anahtar: <a href="https://example.test">Laboratuvar</a>'));
|
||||||
|
assert.ok(!message.html.includes(criteria));
|
||||||
|
assert.ok(message.subject.includes(criteria),'the non-HTML subject retains the literal criterion');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user