fix: reject stale company profile and filter updates

This commit is contained in:
2026-09-06 13:58:25 +03:00
parent ae32382ffa
commit 9d3acac59d
8 changed files with 92 additions and 25 deletions
+8
View File
@@ -964,3 +964,11 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
- Belge deposu her dosyanın indirme işlemini kilitler, düğmede İndiriliyor durumu gösterir; erişilemeyen dosyanın düğmesi devre dışıdır. Depodan çıkılınca bekleyen indirmeler iptal edilir. Aynı e-posta için token yenilenmesi de panel oturumunu yeniden kurar.
- 220 otomatik test ve üretim derlemesi geçti. Yeni testler takılmış başarılı/hatalı yanıt gövdesi, iptal, oturumsuz istek, özgün Türkçe içerik ve dosya adı, tıklama hatasında kaynak temizliğini kapsar. Son küçük hata-mesajı sağlamlaştırmasından sonra belge oturum testleri ayrıca geçti.
- Gerçek tarayıcı Downloads klasöründe dosya kabulü ve yavaş ağda İndiriliyor görünümü henüz doğrulanmadı. Tarayıcıya teslim, dosyanın diske yazıldığının garantisi olarak sunulmaz. Canlıya yayın yapılmadı.
### Firma profili ve kayıtlı filtrelerde eşzamanlı yazma koruması — 6 Eylül 2026
- Kullanıcı verisi GET yanıtı, aynı SELECT sonucundan veri ve içerik sürümlerini birlikte döndürür. Firma profili ve saved_filters yazımında okunan sürüm zorunludur. SQLite BEGIN IMMEDIATE içinde mevcut içerik sürümü karşılaştırılır; farklıysa 409 VERSION_CONFLICT döner ve mevcut kayıt korunur.
- Sürüm mevcut ham JSON içeriğinin SHA-256 değeridir; sayaç değildir. Aynı içerik yeniden kaydedildiğinde sürüm değişmez. Eski kayıtlar şema değişikliği olmadan sürüm alır; bulunmayan kaydın başlangıç sürümü null'dır.
- İstemci hesabın ilk yüklemesinde sürümleri saklar, profil/filtre kaydında gönderir ve yalnız başarılı yanıtta günceller. Çakışmada düzenleme otomatik tekrar gönderilmez; hata kullanıcının girdilerini kopyalayıp güncel kayıtla yeniden düzenlemesini ister. Profil formundaki kaydedilmemiş girdiler korunur.
- Her iki anahtarda sekizer aynı sürümlü eşzamanlı HTTP isteğinden yalnız biri başarılı, yedisi 409 oldu. Eski sürümle sonraki istek mevcut değeri değiştirmedi; başka hesap aynı sürümle hedef kaydı değiştiremedi. Eksik sürüm 428 aldı. Eski depo kaydı, tekrar açma ve aynı içerik davranışı test edildi.
- 222 otomatik test ve üretim derlemesi geçti. Önceki yazma-hatası testinin temizlemediği test trigger'ı finally ile temizlenerek testler arası sızıntı giderildi.
- İki tarayıcı sekmesindeki hata/kurtarma kabulü henüz yapılmadı. Ekip, favoriler ve diğer genel anahtarların eşzamanlı yazma koruması bu değişiklikte tamamlanmadı. Canlıya yayın yapılmadı; eski QA backend örnekleri yeni sürüm sözleşmesini sunmaz.
+3
View File
@@ -72,3 +72,6 @@ Kalıcı e-posta kuyruğu aynı notify.sqlite içinde `notification_outbox` tabl
### Güncellik nedeniyle tutulan e-postalar
Kuyruktaki ilanın artık kaynakta bulunmaması, teklif zamanının değişmesi, iptal/sonuç veya süre aşımı halinde otomatik tekrar durur ve yönetici inceleme listesinde gerekçe görünür. Liste sınırlaması veya geçici kaynak eksikliği de bu durumu oluşturabilir. Önce sağlayıcı kaydını ve resmi ilanı karşılaştırın; mevcut karar akışından gönderildiğini kanıtlayarak veya yeniden göndermeden kapatarak çözün. Payload ya da idempotency anahtarını elle değiştirmeyin. Yeniden göndermeden kapatmak bu gruptaki ihale kimliklerini görülmüş sayar; değişen ilan için otomatik yeni e-posta taahhüt edilmez.
### Firma profili / kayıtlı filtre sürüm sözleşmesi
`GET /api/me/data` artık `veri` ile aynı okumaya ait `revisions` döndürür. `firma_profil` ve `saved_filters` PUT/POST istekleri `revision` göndermelidir (yeni kayıt için null). Eksik sürüm 428, değişmiş sürüm 409 üretir; otomatik üzerine yazma veya otomatik tekrar yoktur. Frontend ve backend birlikte sürümlenmelidir. Eski açık sekme 428 alırsa kaydedilmemiş girdiler korunup sayfa yenilenmelidir. Veritabanı şeması değişmez; eski sunucuya dönmek bu iki anahtardaki çakışma korumasını kaldırır. Yayın kabulünde iki sekmenin aynı kaydı değiştirmesi ve reddedilen sekmenin girdilerinin korunması sınanmalıdır.
+7 -4
View File
@@ -1194,7 +1194,7 @@ const server = http.createServer(async (req, res) => {
if (req.method === 'GET') {
if (anahtar) return json(res, 200, { success: true, anahtar, deger: store.getData(user.email, anahtar) });
return json(res, 200, { success: true, veri: store.getAllData(user.email) });
return json(res, 200, { success: true, ...store.getDataSnapshot(user.email) });
}
if (req.method === 'PUT' || req.method === 'POST') {
try {
@@ -1205,10 +1205,13 @@ const server = http.createServer(async (req, res) => {
if (k === 'ekip' && Array.isArray(body.deger)) {
kotaKontrol(user.email, 'ekip', 0, body.deger.length, { isAdmin: user.isAdmin });
}
store.setData(user.email, k, body.deger);
return json(res, 200, { success: true });
const protectedKey=['firma_profil','saved_filters'].includes(k);
if(protectedKey && (!Object.hasOwn(body,'revision') || (body.revision!==null && (typeof body.revision!=='string' || !/^[a-f0-9]{64}$/.test(body.revision)))))
return json(res,428,{success:false,kod:'VERSION_REQUIRED',error:'Güncel kayıt sürümü gerekli. Sayfayı yenileyip tekrar deneyin.'});
const revision=store.setData(user.email, k, body.deger, protectedKey ? body.revision : undefined);
return json(res, 200, { success: true, ...(protectedKey ? {revision} : {}) });
} catch (e) {
return json(res, e.kod === 'KOTA_ASILDI' ? 402 : 400, { success: false, error: e.message, kod: e.kod || null });
return json(res, e.kod === 'KOTA_ASILDI' ? 402 : e.kod === 'VERSION_CONFLICT' ? 409 : 400, { success: false, error: e.message, kod: e.kod || null });
}
}
return json(res, 405, { success: false, error: 'desteklenmeyen metot' });
+24 -11
View File
@@ -202,24 +202,37 @@ export function getData(email, anahtar) {
});
}
export function getAllData(email) {
return withDb((db) => {
const rows = db.prepare('SELECT anahtar, deger FROM kullanici_verisi WHERE email=?').all(email);
const out = {};
for (const r of rows) {
try { out[r.anahtar] = JSON.parse(r.deger); } catch { /* bozuk kayıt atlanır */ }
const dataRevision = raw => raw == null ? null : crypto.createHash('sha256').update(raw).digest('hex');
export function getDataSnapshot(email) {
return withDb(db=>{
const rows=db.prepare('SELECT anahtar,deger FROM kullanici_verisi WHERE email=?').all(email);
const veri={},revisions={};
for(const row of rows){
revisions[row.anahtar]=dataRevision(row.deger);
try{veri[row.anahtar]=JSON.parse(row.deger);}catch{/* invalid data remains absent */}
}
return out;
}, {});
return {veri,revisions};
},{veri:{},revisions:{}});
}
export function getAllData(email) { return getDataSnapshot(email).veri; }
export function setData(email, anahtar, deger) {
export function setData(email, anahtar, deger, expectedRevision=undefined) {
if (!IZINLI_ANAHTARLAR.has(anahtar)) throw new Error(`Bilinmeyen veri anahtarı: ${anahtar}`);
return withDb((db) => {
db.exec('BEGIN IMMEDIATE');
try {
const old=db.prepare('SELECT deger FROM kullanici_verisi WHERE email=? AND anahtar=?').get(email,anahtar);
if(expectedRevision!==undefined && expectedRevision!==dataRevision(old?.deger)) {
const error=new Error('Bu kayıt başka bir sekmede veya cihazda değişti. Değişikliklerinizi bir yere kopyalayıp sayfayı yenileyin; güncel kayıt üzerinde tekrar düzenleyin.');
error.kod='VERSION_CONFLICT';throw error;
}
const raw=JSON.stringify(deger ?? null);
db.prepare(`INSERT INTO kullanici_verisi (email, anahtar, deger, guncelleme) VALUES (?,?,?,?)
ON CONFLICT(email, anahtar) DO UPDATE SET deger=excluded.deger, guncelleme=excluded.guncelleme`)
.run(email, anahtar, JSON.stringify(deger ?? null), now());
return true;
.run(email, anahtar, raw, now());
db.exec('COMMIT');
return expectedRevision===undefined ? true : dataRevision(raw);
} catch(error) {db.exec('ROLLBACK');throw error;}
}, false);
}
+10 -5
View File
@@ -8,7 +8,7 @@ import { havuzuPuanla, profilMetni, profilTerimleri } from '../services/uyum';
import { getToken } from '../services/auth';
import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi';
import { restoreSession, clearSession, getStoredUser } from '../services/auth';
import { getAllUserData, setUserData, getPlan, bildirimleriGetir, bildirimOkundu, listDocuments } from '../services/account';
import { getUserDataSnapshot, setUserData, getPlan, bildirimleriGetir, bildirimOkundu, listDocuments } from '../services/account';
import { applyTenderStates, tenderStateKey, tenderSnapshot, publicTender, tenderSourceChanges } from '../services/tenderState';
import { getTenderStates, saveTenderState } from '../services/account';
import { SECTORS_LIST, INSTITUTION_TYPES_LIST } from '../data/constants';
@@ -160,10 +160,12 @@ export const AppProvider = ({ children }) => {
} catch { /* depolama kapalıysa kart bugünü gösterir */ }
}, [authUser?.email]);
const dataRevisions=useRef({});
// Giriş yapınca kullanıcı verisini SUNUCUDAN yükle (localStorage'ı ezer)
useEffect(() => {
notificationVersion.current++;notificationMutation.current=null;seenServerNotifications.current.clear();setNotificationError(null);setNotificationBusy(false);
aiRunRef.current=null;setAiAnalyzing(false);setAiJobStatus('');
dataRevisions.current={};
setVeriSenkron(false); setPlan(null); setSaveError(null);
setFavoriteKurumlar([]); setTeamMembers([]); setSavedFilters([]); setCompetitorWatchlist([]);
setReminderSettings(defaultReminder()); setUserProfile(defaultProfile(authUser));
@@ -176,9 +178,10 @@ export const AppProvider = ({ children }) => {
ilkYazmaAtlanan.current.clear();
savedTenderStates.current = {};
const loadSession = getToken();
Promise.all([getAllUserData(), getTenderStates()])
.then(([v, records]) => {
Promise.all([getUserDataSnapshot(), getTenderStates()])
.then(([snapshot, records]) => {
if (cancelled || getToken() !== loadSession) return;
const v=snapshot.veri;dataRevisions.current=snapshot.revisions;
setFavoriteKurumlar(v.favori_kurumlar || []);
setTeamMembers(v.ekip || []);
setSavedFilters(v.saved_filters || []);
@@ -525,8 +528,9 @@ export const AppProvider = ({ children }) => {
profileSaveLock.current=true;
try {
const profile={company:userProfile.company,website,scrapedCapabilities:capabilities,uyumEsigi:Math.max(0,Math.min(100,Number(threshold)||0))};
await setUserData('firma_profil',profile);
const saved=await setUserData('firma_profil',profile,dataRevisions.current.firma_profil ?? null);
if(getToken()!==session)throw new Error('Oturum değişti; profil bu hesaba aktarılmadı.');
dataRevisions.current.firma_profil=saved.revision;
setUserProfile(previous=>({...previous,...profile}));
return profile;
}finally{profileSaveLock.current=false;}
@@ -682,8 +686,9 @@ export const AppProvider = ({ children }) => {
if(filterSaveLock.current){setSaveError('Önceki filtre kaydı sürüyor.');return false;}
const session=getToken();filterSaveLock.current=true;setSavingCount(n=>n+1);setSaveError(null);
try{
await setUserData('saved_filters',next);
const saved=await setUserData('saved_filters',next,dataRevisions.current.saved_filters ?? null);
if(getToken()!==session)return false;
dataRevisions.current.saved_filters=saved.revision;
setSavedFilters(next);return true;
}catch(error){if(getToken()===session)setSaveError(error.message);return false;}
finally{filterSaveLock.current=false;setSavingCount(n=>n-1);}
+5 -2
View File
@@ -18,8 +18,11 @@ export const listTenderDocuments = (ikn) =>
// --- Kullanıcı verisi ---
export const getAllUserData = () => iste('/api/me/data').then((d) => requireObject(d,'veri'));
export const setUserData = (anahtar, deger) =>
iste('/api/me/data', { method: 'PUT', body: JSON.stringify({ anahtar, deger }) });
export const getUserDataSnapshot = () => iste('/api/me/data').then(data=>{
requireObject(data,'veri');requireObject(data,'revisions');return data;
});
export const setUserData = (anahtar, deger, revision=undefined) =>
iste('/api/me/data', { method: 'PUT', body: JSON.stringify({ anahtar, deger, revision }) });
// --- API anahtarları ---
export const listApiKeys = () => apiKeyRequest();
+18
View File
@@ -199,3 +199,21 @@ test('outbox review endpoint requires administrator and rejects unsupported or i
assert.equal((await request('/api/admin/notify/outbox','POST',{id:'x',action:'skip',note:'short'},admin)).status,400);
assert.equal((await request('/api/admin/notify/outbox','POST',{id:'missing',action:'skip',note:'Sağlayıcı kaydı kontrol edildi ve kapatılıyor.'},admin)).status,409);
});
test('profile and saved-filter writes require the loaded revision and reject concurrent overwrites',async()=>{
for(const anahtar of ['firma_profil','saved_filters']){
const deger=anahtar==='firma_profil'?{company:'QA başlangıç'}:[{id:'qa',name:'Başlangıç'}];
assert.equal((await request('/api/me/data','PUT',{anahtar,deger})).status,428);
const initial=await request('/api/me/data');const revision=initial.data.revisions[anahtar] ?? null;
const first=await request('/api/me/data','PUT',{anahtar,deger,revision});assert.equal(first.status,200);assert.match(first.data.revision,/^[a-f0-9]{64}$/);
const attempts=await Promise.all(Array.from({length:8},(_,i)=>request('/api/me/data','PUT',{anahtar,deger:anahtar==='firma_profil'?{company:'QA '+i}:[{id:'qa'+i}],revision:first.data.revision})));
assert.equal(attempts.filter(x=>x.status===200).length,1);
assert.equal(attempts.filter(x=>x.status===409&&x.data.kod==='VERSION_CONFLICT').length,7);
const after=await request('/api/me/data');assert.equal(after.data.revisions[anahtar],attempts.find(x=>x.status===200).data.revision);
const stale=await request('/api/me/data','PUT',{anahtar,deger,revision:first.data.revision});assert.equal(stale.status,409);
assert.deepEqual((await request('/api/me/data')).data.veri[anahtar],after.data.veri[anahtar]);
// A revision read under another owner cannot grant write access to this record.
const foreign=await request('/api/me/data','PUT',{anahtar,deger,revision:after.data.revisions[anahtar]},other);assert.equal(foreign.status,409);
assert.equal((await request('/api/me/data','PUT',{anahtar,deger,revision:null},other)).status,200);
}
});
+15 -1
View File
@@ -43,7 +43,8 @@ test('database write failure propagates instead of pretending success', () => {
const db = store.openDb();
db.exec(`CREATE TRIGGER reject_write BEFORE INSERT ON kullanici_verisi BEGIN SELECT RAISE(FAIL,'test write failure'); END;`);
db.close();
assert.throws(() => store.setData('a@example.test','takip',[]), /test write failure/);
try { assert.throws(() => store.setData('a@example.test','takip',[]), /test write failure/); }
finally { const cleanup=store.openDb();cleanup.exec('DROP TRIGGER reject_write');cleanup.close(); }
});
test('per-tender versions prevent stale updates; five tasks persist', () => {
for (const id of ['1573235','1582991','1523851','1499061','1534665']) {
@@ -97,3 +98,16 @@ test('committed deletion remains inaccessible and pending physical cleanup survi
const reopened=store.openDb();assert.equal(reopened.prepare('SELECT COUNT(*) n FROM document_file_cleanup').get().n,0);reopened.close();
assert.equal(existsSync(file),false);
});
test('data revision snapshots preserve legacy records and reject stale changes atomically',()=>{
const owner='revision@example.test',key='firma_profil';
store.setData(owner,key,{company:'Legacy'});
const first=store.getDataSnapshot(owner);
assert.deepEqual(first.veri[key],{company:'Legacy'});assert.match(first.revisions[key],/^[a-f0-9]{64}$/);
const next=store.setData(owner,key,{company:'Updated'},first.revisions[key]);assert.notEqual(next,first.revisions[key]);
assert.throws(()=>store.setData(owner,key,{company:'Stale'},first.revisions[key]),e=>e.kod==='VERSION_CONFLICT');
assert.equal(store.getAllData(owner)[key].company,'Updated');
const reopened=store.getDataSnapshot(owner);assert.equal(reopened.revisions[key],next);
assert.equal(store.setData(owner,key,{company:'Updated'},next),next);
assert.throws(()=>store.setData(owner,key,{company:'New'},null),e=>e.kod==='VERSION_CONFLICT');
});