diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 627bcdf..6ef2b81 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -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. - 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ı. + +### 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. diff --git a/server/email.mjs b/server/email.mjs index 322b1b3..e07fd26 100644 --- a/server/email.mjs +++ b/server/email.mjs @@ -76,6 +76,6 @@ export function tenderDigestHtml({ name, tenders }) { `; } -function escapeHtml(s) { +export function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } diff --git a/server/notifier.mjs b/server/notifier.mjs index 8455ded..0e4aa8f 100644 --- a/server/notifier.mjs +++ b/server/notifier.mjs @@ -10,12 +10,28 @@ import {deadlineInfo} from '../src/services/tenderCalendar.js'; */ 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 { getData } from './user-store.mjs'; 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? */ export function eslesiyorMu(t, a) { 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 }; 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 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) }) + `

- 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.

`; diff --git a/tests/notifier-delivery.test.mjs b/tests/notifier-delivery.test.mjs index fd58956..7f4c515 100644 --- a/tests/notifier-delivery.test.mjs +++ b/tests/notifier-delivery.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; 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'); test('notification eligibility excludes expired cancelled and unknown deadlines',()=>{ 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'); 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='Laboratuvar'; + 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'); +});