Enforce API key quota within the database transaction
This commit is contained in:
@@ -623,3 +623,11 @@ Taslak bölümleri istenen başlıklara göre, kaynak sırasıyla birleştirilir
|
|||||||
Doğrulama: 50 bin karakteri aşan kaynakta tüm karakterlerin sağlayıcı yardımcı çağrılarına kayıpsız ulaşması, son koşulun birleşik bölümde korunması, ara parça hatasının görünürlüğü, iptalde sonraki grupların durması. Sağlayıcı yanıtları yerel test taklididir; gerçek uzun belge üretim kalitesi/tarayıcı kabulü değildir. Tüm 135 test ve üretim derlemesi geçti.
|
Doğrulama: 50 bin karakteri aşan kaynakta tüm karakterlerin sağlayıcı yardımcı çağrılarına kayıpsız ulaşması, son koşulun birleşik bölümde korunması, ara parça hatasının görünürlüğü, iptalde sonraki grupların durması. Sağlayıcı yanıtları yerel test taklididir; gerçek uzun belge üretim kalitesi/tarayıcı kabulü değildir. Tüm 135 test ve üretim derlemesi geçti.
|
||||||
|
|
||||||
Sınırlar: Bir parçanın işlenmesi tüm şartlarının anlam bakımından taslağa doğru aktarıldığını kanıtlamaz. İki dakikalık istemci sınırı ile kalıcı iş/sürdürme ihtiyacı açık; gerçek firma ve beş resmi dosya kabulü yapılmadı. Büyük paket uyarıları sürüyor; canlı yayın yok.
|
Sınırlar: Bir parçanın işlenmesi tüm şartlarının anlam bakımından taslağa doğru aktarıldığını kanıtlamaz. İki dakikalık istemci sınırı ile kalıcı iş/sürdürme ihtiyacı açık; gerçek firma ve beş resmi dosya kabulü yapılmadı. Büyük paket uyarıları sürüyor; canlı yayın yok.
|
||||||
|
|
||||||
|
### D09 — API anahtarı kotasının işlem bütünlüğü
|
||||||
|
|
||||||
|
Önceki uç nokta anahtar sayısını okuyup kotayı kontrol ettikten sonra istek gövdesini bekliyordu; bekleyen eşzamanlı istekler aynı boş kapasiteyi kullanabilirdi. Sayım, kota denetimi ve INSERT artık BEGIN IMMEDIATE işlemi içinde; başarısızlık geri alınır. İptal edilmiş anahtarlar aktif kotaya katılmaz. Sunucudaki güncel plan limiti kayıt işlevine aktarılır.
|
||||||
|
|
||||||
|
Bozuk JSON'un boş gövdeye çevrilip varsayılan anahtar oluşturması kaldırıldı. Anahtar adı metin ve en fazla 120 karakter olmalı; çevresindeki boşluklar temizlenir. GET/POST/DELETE dışındaki metotlar 405 döner.
|
||||||
|
|
||||||
|
Doğrulama: yerel izole HTTP testinde eşzamanlı 16 oluşturma isteğinden tam 10 başarı, 6 kota reddi; bir anahtar iptal edilince yeni kayıt mümkün. Bozuk JSON/geçersiz ad kayıt oluşturmuyor. Gerçek SQLite testinde kota reddi sonrası işlem kapanıyor, iptal ve yeni kayıt çalışıyor. Tüm 137 test geçti; ön yüz değişmediğinden derleme tekrarlanmadı. Farklı sunucu süreçlerinden yük testi yapılmadı. Planın diğer kabul işleri ve canlı yayın açık.
|
||||||
|
|||||||
+3
-4
@@ -1174,10 +1174,8 @@ const server = http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
if (req.method === 'POST') {
|
if (req.method === 'POST') {
|
||||||
try {
|
try {
|
||||||
const mevcut = store.listApiKeys(user.email).length;
|
const body = await readBody(req);
|
||||||
kotaKontrol(user.email, 'apiAnahtari', mevcut, 1, { isAdmin: user.isAdmin });
|
const k = store.createApiKey(user.email, body.ad, { limit: haklar(user.email, { isAdmin: user.isAdmin }).apiAnahtari });
|
||||||
const body = await readBody(req).catch(() => ({}));
|
|
||||||
const k = store.createApiKey(user.email, body.ad);
|
|
||||||
// `anahtar` YALNIZCA burada döner; sunucuda yalnızca hash'i saklanır
|
// `anahtar` YALNIZCA burada döner; sunucuda yalnızca hash'i saklanır
|
||||||
return json(res, 200, { success: true, anahtar: k });
|
return json(res, 200, { success: true, anahtar: k });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1188,6 +1186,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const ok = store.revokeApiKey(user.email, url.searchParams.get('id'));
|
const ok = store.revokeApiKey(user.email, url.searchParams.get('id'));
|
||||||
return json(res, ok ? 200 : 404, { success: ok, error: ok ? undefined : 'Anahtar bulunamadı veya bu hesaba ait değil.', anahtarlar: store.listApiKeys(user.email), limit: haklar(user.email, { isAdmin: user.isAdmin }).apiAnahtari });
|
return json(res, ok ? 200 : 404, { success: ok, error: ok ? undefined : 'Anahtar bulunamadı veya bu hesaba ait değil.', anahtarlar: store.listApiKeys(user.email), limit: haklar(user.email, { isAdmin: user.isAdmin }).apiAnahtari });
|
||||||
}
|
}
|
||||||
|
if (req.method !== 'GET') return json(res, 405, { success: false, error: 'Desteklenmeyen metot.' });
|
||||||
return json(res, 200, { success: true, anahtarlar: store.listApiKeys(user.email), limit: haklar(user.email, { isAdmin: user.isAdmin }).apiAnahtari });
|
return json(res, 200, { success: true, anahtarlar: store.listApiKeys(user.email), limit: haklar(user.email, { isAdmin: user.isAdmin }).apiAnahtari });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-7
@@ -224,13 +224,28 @@ export function setData(email, anahtar, deger) {
|
|||||||
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
|
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
|
||||||
|
|
||||||
/** Yeni anahtar üretir. Ham değer YALNIZCA bir kez döner, saklanmaz. */
|
/** Yeni anahtar üretir. Ham değer YALNIZCA bir kez döner, saklanmaz. */
|
||||||
export function createApiKey(email, ad) {
|
export function createApiKey(email, ad, { limit = null } = {}) {
|
||||||
const ham = 'ih_live_' + crypto.randomBytes(24).toString('base64url');
|
if (ad != null && typeof ad !== 'string') throw new Error('Anahtar adı metin olmalı.');
|
||||||
const id = crypto.randomUUID();
|
const name = ad?.trim() || 'API Anahtarı';
|
||||||
withDb((db) => db.prepare(
|
if (name.length > 120) throw new Error('Anahtar adı en fazla 120 karakter olabilir.');
|
||||||
'INSERT INTO api_anahtarlari (id,email,ad,onek,hash,olusturma) VALUES (?,?,?,?,?,?)'
|
if (limit !== null && (!Number.isInteger(limit) || limit < 0)) throw new Error('Geçersiz anahtar kotası.');
|
||||||
).run(id, email, ad || 'API Anahtarı', ham.slice(0, 12), sha256(ham), now()));
|
return withDb((db) => {
|
||||||
return { id, ad: ad || 'API Anahtarı', onek: ham.slice(0, 12), anahtar: ham, olusturma: now() };
|
db.exec('BEGIN IMMEDIATE');
|
||||||
|
try {
|
||||||
|
const count = db.prepare('SELECT COUNT(*) n FROM api_anahtarlari WHERE email=? AND iptal=0').get(email).n;
|
||||||
|
if (limit !== null && count >= limit) {
|
||||||
|
const error = new Error(`Planınızda en fazla ${limit} API anahtarı oluşturabilirsiniz. Kullanılmayan bir anahtarı iptal edin veya Planlar sekmesini kontrol edin.`);
|
||||||
|
error.kod = 'KOTA_ASILDI';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const ham = 'ih_live_' + crypto.randomBytes(24).toString('base64url');
|
||||||
|
const id = crypto.randomUUID(), olusturma = now();
|
||||||
|
db.prepare('INSERT INTO api_anahtarlari (id,email,ad,onek,hash,olusturma) VALUES (?,?,?,?,?,?)')
|
||||||
|
.run(id, email, name, ham.slice(0, 12), sha256(ham), olusturma);
|
||||||
|
db.exec('COMMIT');
|
||||||
|
return { id, ad:name, onek:ham.slice(0,12), anahtar:ham, olusturma };
|
||||||
|
} catch (error) { db.exec('ROLLBACK'); throw error; }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listApiKeys(email) {
|
export function listApiKeys(email) {
|
||||||
|
|||||||
@@ -155,3 +155,20 @@ test('HTTP API key creation, private listing, MCP use and revocation form a comp
|
|||||||
assert.deepEqual(revoked.data.anahtarlar,[]);
|
assert.deepEqual(revoked.data.anahtarlar,[]);
|
||||||
assert.equal((await request('/mcp','POST',rpc,anahtar)).status,401);
|
assert.equal((await request('/mcp','POST',rpc,anahtar)).status,401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('concurrent API key requests never exceed quota and malformed input creates nothing',async()=>{
|
||||||
|
const auth=signToken({email:'quota-http@example.test',name:'Quota QA',isAdmin:true});
|
||||||
|
const pending=Array.from({length:16},(_,i)=>request('/api/me/apikeys','POST',{ad:`QA ${i}`},auth));
|
||||||
|
const results=await Promise.all(pending);
|
||||||
|
assert.equal(results.filter(r=>r.status===200).length,10);
|
||||||
|
assert.equal(results.filter(r=>r.status===402&&r.data.kod==='KOTA_ASILDI').length,6);
|
||||||
|
const keys=await request('/api/me/apikeys','GET',null,auth);
|
||||||
|
assert.equal(keys.data.anahtarlar.length,10);
|
||||||
|
await request('/api/me/apikeys?id='+keys.data.anahtarlar[0].id,'DELETE',null,auth);
|
||||||
|
const invalid=await fetch(base+'/api/me/apikeys',{method:'POST',headers:{Authorization:'Bearer '+auth,'Content-Type':'application/json'},body:'{broken'});
|
||||||
|
assert.equal(invalid.status,400);
|
||||||
|
assert.equal((await request('/api/me/apikeys','POST',{ad:{}},auth)).status,400);
|
||||||
|
assert.equal((await request('/api/me/apikeys','GET',null,auth)).data.anahtarlar.length,9);
|
||||||
|
assert.equal((await request('/api/me/apikeys','POST',{ad:'replacement'},auth)).status,200);
|
||||||
|
assert.equal((await request('/api/me/apikeys','PUT',{},auth)).status,405);
|
||||||
|
});
|
||||||
|
|||||||
@@ -56,3 +56,16 @@ test('per-tender versions prevent stale updates; five tasks persist', () => {
|
|||||||
assert.equal(Object.keys(store.getTenderStates('c@example.test')).length,5);
|
assert.equal(Object.keys(store.getTenderStates('c@example.test')).length,5);
|
||||||
assert.deepEqual(store.getTenderStates('d@example.test'),{});
|
assert.deepEqual(store.getTenderStates('d@example.test'),{});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('API key quota is transactional and revoked slots can be reused', () => {
|
||||||
|
const email='quota@example.test';
|
||||||
|
const key=store.createApiKey(email,' ERP ',{limit:1});
|
||||||
|
assert.equal(key.ad,'ERP');
|
||||||
|
assert.throws(()=>store.createApiKey(email,'excess',{limit:1}), {kod:'KOTA_ASILDI'});
|
||||||
|
assert.equal(store.listApiKeys(email).length,1);
|
||||||
|
assert.equal(store.revokeApiKey(email,key.id),true);
|
||||||
|
assert.ok(store.createApiKey(email,'replacement',{limit:1}).id);
|
||||||
|
assert.equal(store.listApiKeys(email).length,1);
|
||||||
|
for(const ad of [{},[],42,'x'.repeat(121)])assert.throws(()=>store.createApiKey('invalid-name@example.test',ad));
|
||||||
|
assert.deepEqual(store.listApiKeys('invalid-name@example.test'),[]);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user