Bound account requests and reject stale or malformed private responses
This commit is contained in:
@@ -697,3 +697,11 @@ Kullanıcı yedeğine ek olarak bağımsız veri volume'u aracı eklendi. Seçil
|
|||||||
Testte iki depo (kalem ve kaynak geçmişi adlarıyla), committed WAL kaydı, alt dizin JSON dosyası yeni volume'a geri yüklendi. Üzerine yazma ve tahrif edilmiş yedek reddi geçti. Tüm 149 test başarılı. Üretim ön yüzü değişmediğinden derleme tekrarlanmadı. Runbook komutları ve sınırlar güncellendi.
|
Testte iki depo (kalem ve kaynak geçmişi adlarıyla), committed WAL kaydı, alt dizin JSON dosyası yeni volume'a geri yüklendi. Üzerine yazma ve tahrif edilmiş yedek reddi geçti. Tüm 149 test başarılı. Üretim ön yüzü değişmediğinden derleme tekrarlanmadı. Runbook komutları ve sınırlar güncellendi.
|
||||||
|
|
||||||
Sınırlar: seçilen dizinler dışındaki worker depoları/config/env ayrıca envanter gerektirir; kullanıcı belgesi referans kontrolü kullanıcı yedek aracındadır. Gerçek servis durdurma, tüm volume'ların aynı sürüm setiyle alınması ve platformda geri dönüş yapılmadı. Tam D11 ve canlı yayın açık.
|
Sınırlar: seçilen dizinler dışındaki worker depoları/config/env ayrıca envanter gerektirir; kullanıcı belgesi referans kontrolü kullanıcı yedek aracındadır. Gerçek servis durdurma, tüm volume'ların aynı sürüm setiyle alınması ve platformda geri dönüş yapılmadı. Tam D11 ve canlı yayın açık.
|
||||||
|
|
||||||
|
### D01 / D09 — Ortak hesap isteklerinin yaşam süresi
|
||||||
|
|
||||||
|
Plan, belge, kullanıcı verisi, bildirim ve hazırlık kaydı servislerinin ortak istemcisine oturuma bağlı istek/yanıt kontrolü, iptal sinyali ve 30 saniye zaman aşımı eklendi. Süre yalnız fetch başlığını değil yanıt gövdesinin okunmasını da kapsar. Dosya yükleme/işleme uç noktası için süre 120 saniye; kalıcı iş modeli bu değişiklikle tamamlanmış değildir.
|
||||||
|
|
||||||
|
Başlangıç token'ı istek boyunca sabittir; hesap değişimi sonrası özel yanıt döndürülmez. HTTP hata durumu, gövdede success=true olsa bile başarı sayılmaz. VERSION_CONFLICT/KOTA_ASILDI gibi kodlar korunur. Belge/bildirim listesi ve kullanıcı/hazırlık kayıtlarında eksik/geçersiz veri boş koleksiyona dönüştürülmez. Zaman aşımında kayıt işleminin sunucuda gerçekleşmiş olabileceği belirtilir; otomatik mutasyon tekrarı yoktur.
|
||||||
|
|
||||||
|
Test: yanlış şekilli başarılı yanıtlar, gerçek boş liste, HTTP 503, çatışma kodu, hiç tamamlanmayan JSON gövdesi, yazmada tek çağrı, geç özel yanıtta hesap değişimi ve önceden iptal. Tüm 152 test ve üretim derlemesi geçti. Tüm ekranların tarayıcı hata/yeniden deneme kabulü ve uzun işleri sürdürme hâlâ açık. Büyük paket uyarıları sürüyor; canlı yayın yok.
|
||||||
|
|||||||
+8
-24
@@ -6,34 +6,17 @@ import { sessionGuard } from './sessionGuard.js';
|
|||||||
*/
|
*/
|
||||||
import { getToken } from './auth.js';
|
import { getToken } from './auth.js';
|
||||||
|
|
||||||
const auth = () => ({ Authorization: `Bearer ${getToken()}`, 'Content-Type': 'application/json' });
|
import { accountRequest as iste, requireArray, requireObject } from './accountRequest.js';
|
||||||
|
|
||||||
async function iste(url, opts = {}) {
|
|
||||||
const res = await fetch(url, { ...opts, headers: { ...auth(), ...(opts.headers || {}) } });
|
|
||||||
const d = await res.json().catch(() => ({}));
|
|
||||||
if (!d.success) {
|
|
||||||
// nginx/proxy hataları JSON değil HTML döner; kullanıcıya anlamlı mesaj ver.
|
|
||||||
const proxyMesaj = {
|
|
||||||
413: 'Dosya sunucunun kabul ettiği boyutu aşıyor (en fazla 10 MB).',
|
|
||||||
502: 'Sunucuya ulaşılamadı, biraz sonra tekrar deneyin.',
|
|
||||||
504: 'Sunucu zamanında yanıt vermedi; büyük dosyada tekrar deneyin.',
|
|
||||||
}[res.status];
|
|
||||||
const e = new Error(d.error || proxyMesaj || `İstek başarısız (HTTP ${res.status})`);
|
|
||||||
e.kod = d.kod;
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Plan & kota ---
|
// --- Plan & kota ---
|
||||||
export const getPlan = () => iste('/api/me/plan');
|
export const getPlan = () => iste('/api/me/plan');
|
||||||
|
|
||||||
/** Belirli bir ihaleye yüklenmiş belgeler */
|
/** Belirli bir ihaleye yüklenmiş belgeler */
|
||||||
export const listTenderDocuments = (ikn) =>
|
export const listTenderDocuments = (ikn) =>
|
||||||
iste(`/api/me/documents?ikn=${encodeURIComponent(ikn)}`).then((d) => d.belgeler || []);
|
iste(`/api/me/documents?ikn=${encodeURIComponent(ikn)}`).then((d) => requireArray(d,'belgeler'));
|
||||||
|
|
||||||
// --- Kullanıcı verisi ---
|
// --- Kullanıcı verisi ---
|
||||||
export const getAllUserData = () => iste('/api/me/data').then((d) => d.veri || {});
|
export const getAllUserData = () => iste('/api/me/data').then((d) => requireObject(d,'veri'));
|
||||||
export const setUserData = (anahtar, deger) =>
|
export const setUserData = (anahtar, deger) =>
|
||||||
iste('/api/me/data', { method: 'PUT', body: JSON.stringify({ anahtar, deger }) });
|
iste('/api/me/data', { method: 'PUT', body: JSON.stringify({ anahtar, deger }) });
|
||||||
|
|
||||||
@@ -43,7 +26,7 @@ export const createApiKey = (ad) => apiKeyRequest('POST', { ad });
|
|||||||
export const revokeApiKey = (id) => apiKeyRequest('DELETE', { id });
|
export const revokeApiKey = (id) => apiKeyRequest('DELETE', { id });
|
||||||
|
|
||||||
// --- Belgeler ---
|
// --- Belgeler ---
|
||||||
export const listDocuments = () => iste('/api/me/documents');
|
export const listDocuments = () => iste('/api/me/documents').then(d=>{requireArray(d,'belgeler');return d;});
|
||||||
export const deleteDocument = (id) => iste(`/api/me/documents?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
|
export const deleteDocument = (id) => iste(`/api/me/documents?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
|
||||||
export async function uploadDocument(file, kategori, ikn, ek = {}) {
|
export async function uploadDocument(file, kategori, ikn, ek = {}) {
|
||||||
@@ -56,6 +39,7 @@ export async function uploadDocument(file, kategori, ikn, ek = {}) {
|
|||||||
});
|
});
|
||||||
if (!current()) throw new Error('Oturum değişti; dosyayı yeniden seçin.');
|
if (!current()) throw new Error('Oturum değişti; dosyayı yeniden seçin.');
|
||||||
return iste('/api/me/documents', {
|
return iste('/api/me/documents', {
|
||||||
|
timeoutMs:120000,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ ad: file.name, mime: file.type, kategori, ikn, icerikBase64, ...ek }),
|
body: JSON.stringify({ ad: file.name, mime: file.type, kategori, ikn, icerikBase64, ...ek }),
|
||||||
});
|
});
|
||||||
@@ -106,12 +90,12 @@ export const telegramTest = () => iste('/api/me/telegram', { method: 'POST', bod
|
|||||||
export const telegramSil = () => iste('/api/me/telegram', { method: 'DELETE' });
|
export const telegramSil = () => iste('/api/me/telegram', { method: 'DELETE' });
|
||||||
|
|
||||||
// --- Takip bildirimleri (zeyilname / sonuç) ---
|
// --- Takip bildirimleri (zeyilname / sonuç) ---
|
||||||
export const bildirimleriGetir = () => iste('/api/me/bildirimler').then((d) => d.bildirimler || []);
|
export const bildirimleriGetir = () => iste('/api/me/bildirimler').then((d) => requireArray(d,'bildirimler'));
|
||||||
export const bildirimOkundu = (idler) => iste('/api/me/bildirimler', { method: 'POST', body: JSON.stringify({ idler }) });
|
export const bildirimOkundu = (idler) => iste('/api/me/bildirimler', { method: 'POST', body: JSON.stringify({ idler }) });
|
||||||
|
|
||||||
export const getTenderStates = () => iste('/api/me/tender-state').then(d => d.records || {});
|
export const getTenderStates = () => iste('/api/me/tender-state').then(d => requireObject(d,'records'));
|
||||||
export const saveTenderState = (key, patch, snapshot, version) =>
|
export const saveTenderState = (key, patch, snapshot, version) =>
|
||||||
iste('/api/me/tender-state', {method:'PUT',body:JSON.stringify({key,patch,snapshot,version})}).then(d => d.record);
|
iste('/api/me/tender-state', {method:'PUT',body:JSON.stringify({key,patch,snapshot,version})}).then(d => requireObject(d,'record'));
|
||||||
|
|
||||||
export const reportDocumentReadError = (id, expectedProcessing) =>
|
export const reportDocumentReadError = (id, expectedProcessing) =>
|
||||||
iste('/api/me/documents/read-error', { method:'POST', body:JSON.stringify({id,expectedProcessing:expectedProcessing ?? null}) });
|
iste('/api/me/documents/read-error', { method:'POST', body:JSON.stringify({id,expectedProcessing:expectedProcessing ?? null}) });
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import {getToken} from './auth.js';
|
||||||
|
import {awaitWithAbort} from './abortable.js';
|
||||||
|
|
||||||
|
export async function accountRequest(url, {signal,timeoutMs=30000,...options}={}) {
|
||||||
|
const token=getToken(), controller=new AbortController();
|
||||||
|
const cancel=()=>controller.abort(signal.reason);
|
||||||
|
if(signal?.aborted)cancel();else signal?.addEventListener('abort',cancel,{once:true});
|
||||||
|
let timedOut=false;
|
||||||
|
const timer=setTimeout(()=>{timedOut=true;controller.abort();},timeoutMs);
|
||||||
|
const assertSession=()=>{if(getToken()!==token)throw new Error('Oturum değişti; güncel hesapla yeniden deneyin.');};
|
||||||
|
try {
|
||||||
|
controller.signal.throwIfAborted();
|
||||||
|
const res=await awaitWithAbort(fetch(url,{...options,signal:controller.signal,headers:{'Content-Type':'application/json',...options.headers,Authorization:`Bearer ${token}`}}),controller.signal);
|
||||||
|
assertSession();
|
||||||
|
let data;
|
||||||
|
try {data=await awaitWithAbort(res.json(),controller.signal);}
|
||||||
|
catch(error) {controller.signal.throwIfAborted();if(!(error instanceof SyntaxError))throw error;}
|
||||||
|
assertSession();
|
||||||
|
if(!res.ok || data?.success!==true) {
|
||||||
|
const proxy={413:'Dosya sunucunun kabul ettiği boyutu aşıyor (en fazla 10 MB).',502:'Sunucuya ulaşılamadı, biraz sonra tekrar deneyin.',504:'Sunucu zamanında yanıt vermedi.'};
|
||||||
|
const error=new Error(data?.error || proxy[res.status] || `İstek başarısız (HTTP ${res.status}).`);
|
||||||
|
error.kod=data?.kod;throw error;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch(error) {
|
||||||
|
assertSession();
|
||||||
|
if(timedOut)throw new Error(!options.method || options.method==='GET'
|
||||||
|
? 'Bilgiler zamanında alınamadı. Yeniden deneyin.'
|
||||||
|
: 'İşlem sonucu zamanında alınamadı. Tekrar işlem yapmadan önce kaydı yenileyin; işlem sunucuda gerçekleşmiş olabilir.');
|
||||||
|
throw error;
|
||||||
|
} finally {clearTimeout(timer);signal?.removeEventListener('abort',cancel);}
|
||||||
|
}
|
||||||
|
export function requireArray(data,field) {
|
||||||
|
if(!Array.isArray(data[field]))throw new Error('Sunucu listeyi eksik veya geçersiz döndürdü. Yeniden deneyin.');
|
||||||
|
return data[field];
|
||||||
|
}
|
||||||
|
export function requireObject(data,field) {
|
||||||
|
const value=data[field];
|
||||||
|
if(!value || typeof value!=='object' || Array.isArray(value))throw new Error('Sunucu kayıtları eksik veya geçersiz döndürdü. Yeniden deneyin.');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {accountRequest} from '../src/services/accountRequest.js';
|
||||||
|
import {getAllUserData,listDocuments,getTenderStates} from '../src/services/account.js';
|
||||||
|
|
||||||
|
test('account requests preserve conflict codes, distinguish malformed lists and reject HTTP errors',async()=>{
|
||||||
|
const original=globalThis.fetch;
|
||||||
|
try {
|
||||||
|
globalThis.fetch=async()=>Response.json({success:true});
|
||||||
|
for(const call of [listDocuments,getAllUserData,getTenderStates])await assert.rejects(call(),/eksik veya geçersiz/);
|
||||||
|
globalThis.fetch=async()=>Response.json({success:false,error:'Conflict',kod:'VERSION_CONFLICT'},{status:409});
|
||||||
|
await assert.rejects(accountRequest('/state'),{kod:'VERSION_CONFLICT'});
|
||||||
|
globalThis.fetch=async()=>Response.json({success:true},{status:503});
|
||||||
|
await assert.rejects(accountRequest('/state'),/503/);
|
||||||
|
globalThis.fetch=async()=>Response.json({success:true,belgeler:[]});
|
||||||
|
assert.deepEqual((await listDocuments()).belgeler,[]);
|
||||||
|
} finally {globalThis.fetch=original;}
|
||||||
|
});
|
||||||
|
test('stalled response bodies time out and uncertain writes are not retried',async()=>{
|
||||||
|
const original=globalThis.fetch;let calls=0;
|
||||||
|
try {
|
||||||
|
globalThis.fetch=async()=>{calls++;return {ok:true,json:()=>new Promise(()=>{})};};
|
||||||
|
await assert.rejects(accountRequest('/state',{method:'PUT',timeoutMs:5}),/sunucuda gerçekleşmiş olabilir/);
|
||||||
|
assert.equal(calls,1);
|
||||||
|
await assert.rejects(accountRequest('/state',{timeoutMs:5}),/Bilgiler zamanında/);
|
||||||
|
} finally {globalThis.fetch=original;}
|
||||||
|
});
|
||||||
|
test('late private account responses and explicit cancellation do not reach the caller',async()=>{
|
||||||
|
const original=globalThis.fetch,storage=Object.getOwnPropertyDescriptor(globalThis,'localStorage');
|
||||||
|
let token='first';Object.defineProperty(globalThis,'localStorage',{configurable:true,value:{getItem:()=>token}});
|
||||||
|
try {
|
||||||
|
globalThis.fetch=async()=>({ok:true,json:async()=>{token='second';return {success:true,veri:{private:'old account'}};}});
|
||||||
|
await assert.rejects(getAllUserData(),/Oturum değişti/);
|
||||||
|
const controller=new AbortController();controller.abort();
|
||||||
|
globalThis.fetch=async()=>{throw new Error('must not be called');};
|
||||||
|
await assert.rejects(accountRequest('/state',{signal:controller.signal}),{name:'AbortError'});
|
||||||
|
} finally {globalThis.fetch=original;if(storage)Object.defineProperty(globalThis,'localStorage',storage);else delete globalThis.localStorage;}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user