From 372c0e76e0bbff15871fd3bf4ad87d02fbbbd7ee Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 6 Sep 2026 10:37:14 +0300 Subject: [PATCH] Fix API key error states and verify revocation lifecycle --- docs/quality/uygulama-durumu.md | 10 ++++ server/index.js | 2 +- src/components/tools/ApiWebhooks.jsx | 89 ++++++++++++++++++++-------- src/services/account.js | 7 ++- src/services/apiKeys.js | 29 +++++++++ tests/api-keys-client.test.mjs | 36 +++++++++++ tests/http-state.test.mjs | 21 +++++++ 7 files changed, 164 insertions(+), 30 deletions(-) create mode 100644 src/services/apiKeys.js create mode 100644 tests/api-keys-client.test.mjs diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 39266f6..7d404e2 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -595,3 +595,13 @@ Ortak kalem araması başarısız olduğunda istemci boş dizi, AppContext ise s Liste kalem araması sürerken sonuçların genişleyebileceğini, hata olduğunda yalnız başlık/kurum/yüklü kart aramasıyla devam edildiğini ve sonuçların eksik olabileceğini açıklar. Kalem aramasını yeniden deneme düğmesi eklendi. Gerçek boş kalem sonucu hata sayılmaz. Doğrulama: boş başarılı sonuç, depo 503 hatası ve iptal edilmiş arama için servis testi. Mevcut sorgu eşleşmesi testleriyle birlikte tüm 129 test ve üretim derlemesi geçti. Yeni hata durumunun tarayıcı kabulü açık. Büyük paket uyarıları sürüyor; canlı yayın yapılmadı. + +### D09 — API anahtarı hata, oturum ve iptal akışı + +İlk listeleme hatasında boş `durum.anahtarlar` erişimi kaldırıldı; yükleme/hata/boş liste ayrı gösterilir. Hesap ve token bazında anahtar ekranı yeniden kurulur; eski oturum yanıtları uygulanmaz. Oluşturma/iptal çift tıklaması engellenir; liste alınmadan oluşturma açılmaz. İptal yanıtı plan limitini korur ve iptal edilen tek seferlik anahtar kutusu temizlenir. Pano başarısı ancak gerçek kopyalama tamamlanınca gösterilir; hata görünürdür. + +API anahtarı istemcisinde 30 saniye zaman aşımı, yanıt biçimi kontrolü ve yanıt sonrası oturum kontrolü eklendi. Sonucu alınamayan mutasyon otomatik tekrarlanmaz; kullanıcı listeyi kontrol etmeye yönlendirilir. Liste yenileme hatası yeni oluşturulmuş anahtarı silmez. + +Doğrulama: boş başarılı liste, 503, geçersiz JSON/veri, geç gelen farklı oturum yanıtı ve zaman aşımında tek oluşturma isteği. Gerçek izole yerel HTTP sunucusunda oluşturma → gizli alanları içermeyen liste → MCP initialize erişimi → son kullanım zamanı → iptal → 401 zinciri geçti. Başka hesap iptal edemedi; iptal yanıtında limit korundu. Test anahtarları geçici veritabanında tutuldu ve çıktıya yazılmadı. + +Sonuç: 132 test başarılı; üretim derlemesi geçti. Tarayıcıda hata/hesap değişimi/kopyalama kabulü henüz yapılmadı. Büyük paket uyarıları ve D01–D11 kalan işleri açık. Canlı yayın yapılmadı. diff --git a/server/index.js b/server/index.js index 6c07d44..6befbf9 100644 --- a/server/index.js +++ b/server/index.js @@ -1186,7 +1186,7 @@ const server = http.createServer(async (req, res) => { } if (req.method === 'DELETE') { const ok = store.revokeApiKey(user.email, url.searchParams.get('id')); - return json(res, ok ? 200 : 404, { success: ok, anahtarlar: store.listApiKeys(user.email) }); + 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, 200, { success: true, anahtarlar: store.listApiKeys(user.email), limit: haklar(user.email, { isAdmin: user.isAdmin }).apiAnahtari }); } diff --git a/src/components/tools/ApiWebhooks.jsx b/src/components/tools/ApiWebhooks.jsx index a4b9259..f619b39 100644 --- a/src/components/tools/ApiWebhooks.jsx +++ b/src/components/tools/ApiWebhooks.jsx @@ -1,7 +1,8 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useApp } from '../../context/AppContext'; import { listApiKeys, createApiKey, revokeApiKey } from '../../services/account'; import { Webhook, Key, Plus, Trash2, Loader2, Copy, Check, AlertTriangle, ShieldCheck, Lock } from 'lucide-react'; +import { getToken } from '../../services/auth.js'; import { McpRssKarti } from './McpRssKarti'; /** @@ -11,6 +12,15 @@ import { McpRssKarti } from './McpRssKarti'; */ export const ApiWebhooks = () => { const { authUser } = useApp(); + return authUser ? :

API anahtarlarınızı yönetmek için giriş yapın.

; +}; + +const ApiKeySession = () => { + const token = useRef(getToken()); + const alive = useRef(true); + const operation = useRef(false); + const copyTimer = useRef(null); + const current = () => alive.current && getToken() === token.current; const [durum, setDurum] = useState(null); const [busy, setBusy] = useState(false); const [hata, setHata] = useState(null); @@ -18,35 +28,60 @@ export const ApiWebhooks = () => { const [yeniAnahtar, setYeniAnahtar] = useState(null); // yalnızca bir kez gösterilir const [kopyalandi, setKopyalandi] = useState(false); - const yukle = () => { - if (!authUser) return; - setHata(null); - listApiKeys().then(setDurum).catch((e) => setHata(e.message)); + const [loading, setLoading] = useState(true); + const yukle = async () => { + if (!current()) return; + setLoading(true); setHata(null); + try { + const data = await listApiKeys(); + if (current()) setDurum(data); + } catch (e) { if (current()) setHata(e.message); } + finally { if (current()) setLoading(false); } }; - useEffect(yukle, [authUser]); + useEffect(() => { + alive.current = true; + void yukle(); + return () => { alive.current = false; clearTimeout(copyTimer.current); }; + }, []); const olustur = async () => { - setBusy(true); setHata(null); setYeniAnahtar(null); + if (operation.current || !current() || !durum || loading) return; + operation.current = true; + setBusy(true); setHata(null); try { - const d = await createApiKey(yeniAd || 'API Anahtarı'); - setYeniAnahtar(d.anahtar); + const d = await createApiKey(yeniAd.trim() || 'API Anahtarı'); + if (!current()) return; + setYeniAnahtar(d.anahtar); setKopyalandi(false); setYeniAd(''); - yukle(); - } catch (e) { setHata(e.message); } - finally { setBusy(false); } + await yukle(); + } catch (e) { if (current()) setHata(e.message); } + finally { operation.current = false; if (current()) setBusy(false); } }; const iptal = async (id) => { + if (operation.current || !current() || loading) return; + operation.current = true; setBusy(true); setHata(null); - try { setDurum(await revokeApiKey(id)); } - catch (e) { setHata(e.message); } - finally { setBusy(false); } + try { + const data = await revokeApiKey(id); + if (!current()) return; + setDurum(data); + setYeniAnahtar((key) => key?.id === id ? null : key); + } catch (e) { if (current()) setHata(e.message); } + finally { operation.current = false; if (current()) setBusy(false); } }; - const kopyala = (t) => { - navigator.clipboard?.writeText(t); - setKopyalandi(true); - setTimeout(() => setKopyalandi(false), 2000); + const kopyala = async (t) => { + try { + if (!navigator.clipboard) throw new Error('clipboard unavailable'); + await navigator.clipboard.writeText(t); + if (!current()) return; + setKopyalandi(true); + clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => { if (current()) setKopyalandi(false); }, 2000); + } catch { + if (current()) { setKopyalandi(false); setHata('Anahtar kopyalanamadı. Anahtarı seçerek elle kopyalayabilirsiniz.'); } + } }; const limit = durum?.limit; @@ -54,7 +89,7 @@ export const ApiWebhooks = () => { return (
- {hata && } + {hata && }
@@ -113,8 +148,10 @@ export const ApiWebhooks = () => { )}
- {!durum && !hata && !hata ? ( + {loading && !durum ? (
yükleniyor…
+ ) : !durum ? ( +

Anahtar listesi alınamadı. Yeniden deneyebilirsiniz.

) : durum.anahtarlar.length === 0 ? (

Henüz anahtar yok.

) : ( @@ -130,7 +167,7 @@ export const ApiWebhooks = () => { {k.son_kullanim ? `son kullanım: ${new Date(k.son_kullanim).toLocaleString('tr-TR')}` : 'hiç kullanılmadı'}

- @@ -141,16 +178,16 @@ export const ApiWebhooks = () => {
setYeniAd(e.target.value)} - placeholder="Anahtar adı (ör. ERP Entegrasyonu)" disabled={kilitli} - className="flex-1 bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-2 text-xs disabled:opacity-50" /> -
{hata && ( -
+

{hata}

diff --git a/src/services/account.js b/src/services/account.js index 39ba36e..0555559 100644 --- a/src/services/account.js +++ b/src/services/account.js @@ -1,3 +1,4 @@ +import { apiKeyRequest } from './apiKeys.js'; import { sessionGuard } from './sessionGuard.js'; /** * Sunucu tarafı hesap servisleri: plan/kota, kullanıcı verisi, API anahtarları, belgeler. @@ -37,9 +38,9 @@ export const setUserData = (anahtar, deger) => iste('/api/me/data', { method: 'PUT', body: JSON.stringify({ anahtar, deger }) }); // --- API anahtarları --- -export const listApiKeys = () => iste('/api/me/apikeys'); -export const createApiKey = (ad) => iste('/api/me/apikeys', { method: 'POST', body: JSON.stringify({ ad }) }); -export const revokeApiKey = (id) => iste(`/api/me/apikeys?id=${encodeURIComponent(id)}`, { method: 'DELETE' }); +export const listApiKeys = () => apiKeyRequest(); +export const createApiKey = (ad) => apiKeyRequest('POST', { ad }); +export const revokeApiKey = (id) => apiKeyRequest('DELETE', { id }); // --- Belgeler --- export const listDocuments = () => iste('/api/me/documents'); diff --git a/src/services/apiKeys.js b/src/services/apiKeys.js new file mode 100644 index 0000000..4c4ed70 --- /dev/null +++ b/src/services/apiKeys.js @@ -0,0 +1,29 @@ +import { getToken } from './auth.js'; + +// A lost mutation response is uncertain: never automatically repeat key creation. +export async function apiKeyRequest(method = 'GET', { id, ad, timeoutMs = 30000 } = {}) { + const token = getToken(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(`/api/me/apikeys${id ? `?id=${encodeURIComponent(id)}` : ''}`, { + method, signal: controller.signal, + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + ...(method === 'POST' ? { body: JSON.stringify({ ad }) } : {}), + }); + const data = await response.json(); + if (getToken() !== token) throw new Error('Oturum değişti; anahtar ekranını yeniden açın.'); + if (!response.ok || data?.success !== true) throw new Error(data?.error || 'API anahtarı işlemi tamamlanamadı.'); + const valid = method === 'POST' + ? typeof data.anahtar?.id === 'string' && typeof data.anahtar?.anahtar === 'string' && data.anahtar.anahtar.startsWith('ih_live_') + : Array.isArray(data.anahtarlar) && data.anahtarlar.every(k => k && typeof k.id === 'string' && typeof k.ad === 'string') && (data.limit === null || (Number.isFinite(data.limit) && data.limit >= 0)); + if (!valid) throw new Error('Anahtar bilgileri eksik veya geçersiz. Listeyi yeniden yükleyin.'); + return data; + } catch (error) { + if (controller.signal.aborted) throw new Error(method === 'GET' + ? 'Anahtar listesi zamanında alınamadı. Yeniden deneyin.' + : 'İşlem sonucu alınamadı. Tekrar işlem yapmadan önce listeyi yenileyin; işlem sunucuda gerçekleşmiş olabilir.'); + if (error instanceof SyntaxError) throw new Error('Sunucu geçerli anahtar bilgisi döndürmedi. Listeyi yeniden yükleyin.'); + throw error; + } finally { clearTimeout(timer); } +} diff --git a/tests/api-keys-client.test.mjs b/tests/api-keys-client.test.mjs new file mode 100644 index 0000000..4a08ba7 --- /dev/null +++ b/tests/api-keys-client.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { apiKeyRequest } from '../src/services/apiKeys.js'; + +test('API key client distinguishes failure, malformed data, and a genuinely empty list', async () => { + const original = globalThis.fetch; + try { + globalThis.fetch = async () => new Response(JSON.stringify({success:true,anahtarlar:[],limit:0})); + assert.deepEqual((await apiKeyRequest()).anahtarlar, []); + globalThis.fetch = async () => new Response(JSON.stringify({success:false,error:'Depo erişilemiyor'}),{status:503}); + await assert.rejects(apiKeyRequest(), /Depo erişilemiyor/); + globalThis.fetch = async () => new Response(JSON.stringify({success:true,anahtarlar:null})); + await assert.rejects(apiKeyRequest(), /eksik veya geçersiz/); + globalThis.fetch = async () => new Response('502'); + await assert.rejects(apiKeyRequest(), /geçerli anahtar bilgisi/); + } finally { globalThis.fetch = original; } +}); + +test('API key client rejects late private responses after session change and does not retry uncertain creation', async () => { + const original = globalThis.fetch, storage = globalThis.localStorage; + let token = 'account-a', calls = 0; + globalThis.localStorage = {getItem:()=>token}; + try { + globalThis.fetch = async () => { + token = 'account-b'; + return new Response(JSON.stringify({success:true,anahtar:{id:'qa',anahtar:'ih_live_qa'}})); + }; + await assert.rejects(apiKeyRequest('POST',{ad:'QA'}), /Oturum değişti/); + globalThis.fetch = async (_url, options) => { + calls++; + return new Promise((_resolve,reject)=>options.signal.addEventListener('abort',()=>reject(options.signal.reason),{once:true})); + }; + await assert.rejects(apiKeyRequest('POST',{ad:'QA',timeoutMs:5}), /sunucuda gerçekleşmiş olabilir/); + assert.equal(calls,1); + } finally { globalThis.fetch=original; globalThis.localStorage=storage; } +}); diff --git a/tests/http-state.test.mjs b/tests/http-state.test.mjs index 06eb22b..407993a 100644 --- a/tests/http-state.test.mjs +++ b/tests/http-state.test.mjs @@ -134,3 +134,24 @@ test('shared item extraction requires an owned tender document and matching tend await request('/api/me/documents?id='+company.data.id,'DELETE'); await request('/api/me/documents?id='+tender.data.id,'DELETE'); }); + +test('HTTP API key creation, private listing, MCP use and revocation form a complete lifecycle', async () => { + const admin=signToken({email:'keys@example.test',name:'Key QA',isAdmin:true}); + const created=await request('/api/me/apikeys','POST',{ad:'QA integration'},admin); + assert.equal(created.status,200); + const {id,anahtar}=created.data.anahtar; + const listed=await request('/api/me/apikeys','GET',null,admin); + assert.equal(listed.data.anahtarlar.length,1); + assert.equal(listed.data.anahtarlar[0].anahtar,undefined); + assert.equal(listed.data.anahtarlar[0].hash,undefined); + assert.equal((await request('/api/me/apikeys?id='+id,'DELETE',null,other)).status,404); + const rpc={jsonrpc:'2.0',id:1,method:'initialize',params:{}}; + assert.equal((await request('/mcp','POST',rpc,anahtar)).status,200); + const used=await request('/api/me/apikeys','GET',null,admin); + assert.ok(used.data.anahtarlar[0].son_kullanim); + const revoked=await request('/api/me/apikeys?id='+id,'DELETE',null,admin); + assert.equal(revoked.status,200); + assert.equal(revoked.data.limit,listed.data.limit); + assert.deepEqual(revoked.data.anahtarlar,[]); + assert.equal((await request('/mcp','POST',rpc,anahtar)).status,401); +});