Reject malformed notification feeds before state changes

This commit is contained in:
2026-09-06 13:18:22 +03:00
parent 3096ca45cd
commit 02625906f2
4 changed files with 59 additions and 5 deletions
+7
View File
@@ -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
View File
@@ -76,6 +76,6 @@ export function tenderDigestHtml({ name, tenders }) {
</div>`; </div>`;
} }
function escapeHtml(s) { export function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
} }
+19 -3
View File
@@ -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>`;
+32 -1
View File
@@ -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: &lt;a href=&quot;https://example.test&quot;&gt;Laboratuvar&lt;/a&gt;'));
assert.ok(!message.html.includes(criteria));
assert.ok(message.subject.includes(criteria),'the non-HTML subject retains the literal criterion');
});