diff --git a/nginx.conf b/nginx.conf index d8b2207..b2a8fff 100644 --- a/nginx.conf +++ b/nginx.conf @@ -41,6 +41,23 @@ server { proxy_connect_timeout 10s; } + # MCP JSON-RPC ucu /api öneki taşımaz. Ayrı proxy olmadan Nginx POST'u + # statik dosya isteği sayıp 405 döndürür ve Node'daki API anahtarı kontrolü + # hiç çalışmaz. + location = /mcp { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_hide_header X-Content-Type-Options; + proxy_hide_header Referrer-Policy; + proxy_hide_header Permissions-Policy; + client_max_body_size 1m; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + proxy_connect_timeout 10s; + } + # Statik varlıklar location /assets/ { root /usr/share/nginx/html; diff --git a/package.json b/package.json index a155c4e..d93603d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "preview": "vite preview", "server": "node server/index.js", "test": "node --test tests/*.test.mjs", - "qa:live-five": "node tests/live-five-probe.mjs" + "qa:live-five": "node tests/live-five-probe.mjs", + "qa:live-integrations": "node tests/live-integrations-probe.mjs" }, "dependencies": { "canvas-confetti": "^1.9.4", diff --git a/server/index.js b/server/index.js index b95653d..de12b74 100644 --- a/server/index.js +++ b/server/index.js @@ -942,7 +942,8 @@ const server = http.createServer(async (req, res) => { if (urunAramaCache.size > 200) urunAramaCache.delete(urunAramaCache.keys().next().value); return json(res, 200, { success: true, ...out }); } catch (e) { - return json(res, 502, { success: false, error: e.message }); + const failure = sourceFailure(e); + return json(res, failure.status, failure.body); } } // OKAS kataloğunda ad ile arama — idarenin kendi sınıflandırma kodunu bulmak için @@ -957,7 +958,8 @@ const server = http.createServer(async (req, res) => { urunAramaCache.set(anahtar, { zaman: Date.now(), veri: kalemler }); return json(res, 200, { success: true, kalemler }); } catch (e) { - return json(res, 502, { success: false, error: e.message }); + const failure = sourceFailure(e); + return json(res, failure.status, failure.body); } } // --- Anlamsal uyum bonusu: profil metni ↔ havuz (sunucuda gömme) --- @@ -1053,7 +1055,8 @@ const server = http.createServer(async (req, res) => { ); return json(res, 200, { success: true, ...out }); } catch (e) { - return json(res, 502, { success: false, error: e.message }); + const failure = sourceFailure(e); + return json(res, failure.status, failure.body); } } if (url.pathname === '/api/kik/karar') { @@ -1063,7 +1066,8 @@ const server = http.createServer(async (req, res) => { const metin = await kararMetni(id); return json(res, 200, { success: true, id, metin }); } catch (e) { - return json(res, 502, { success: false, error: e.message }); + const failure = sourceFailure(e); + return json(res, failure.status, failure.body); } } diff --git a/server/source-error.mjs b/server/source-error.mjs index ea7dd19..6909f0b 100644 --- a/server/source-error.mjs +++ b/server/source-error.mjs @@ -3,7 +3,7 @@ const MANUAL_EKAP_URL = 'https://ekap.kik.gov.tr/EKAP/'; export function sourceFailure(error) { const rawMessage = error?.message || 'Resmi belge kaynağına ulaşılamadı.'; const humanVerification = error?.code === 'HUMAN_VERIFICATION_REQUIRED' - || /HUMAN_VERIFICATION_REQUIRED|EKAP\s+428/i.test(rawMessage); + || /HUMAN_VERIFICATION_REQUIRED|(?:EKAP|KİK)[^\n]{0,120}(?:HTTP\s*)?428|HTTP\s*428/i.test(rawMessage); if (humanVerification) { return { diff --git a/tests/live-integrations-probe.mjs b/tests/live-integrations-probe.mjs new file mode 100644 index 0000000..c750397 --- /dev/null +++ b/tests/live-integrations-probe.mjs @@ -0,0 +1,145 @@ +// D09 read-only production probe. It checks public configuration, source reads +// and unauthenticated guards. It never signs in, sends a message, creates a +// checkout, calls an AI model, changes data or submits a tender. +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +const base = String(process.argv[2] || 'https://ihaleist.com').replace(/\/$/, ''); +const evidencePath = process.env.IHALEIST_LIVE_INTEGRATIONS_EVIDENCE_PATH + || 'docs/quality/evidence/qa-live-d09-integrations.json'; +const timeoutMs = 45_000; + +async function request(path, options = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(`${base}${path}`, { + ...options, + signal: controller.signal, + headers: { + 'User-Agent': 'ihaleist-live-integrations-probe/1.0', + ...(options.headers || {}), + }, + }); + const contentType = response.headers.get('content-type') || ''; + const body = await response.text(); + let json = null; + if (contentType.includes('json')) { + try { json = JSON.parse(body); } catch { /* malformed JSON stays visible as a failed check */ } + } + return { + path, + method: options.method || 'GET', + status: response.status, + contentType, + json, + textChars: body.length, + error: response.ok ? null : String(json?.error?.message || json?.error || body || `HTTP ${response.status}`).slice(0, 300), + }; + } catch (error) { + return { path, method: options.method || 'GET', status: 0, contentType: '', json: null, textChars: 0, error: error?.message || String(error) }; + } finally { + clearTimeout(timer); + } +} + +const raw = {}; +for (const [name, path, options] of [ + ['google', '/api/auth/google/config'], + ['tcmb', '/api/exchange-rates'], + ['documents', '/api/documents/config'], + ['ai', '/api/ai/config'], + ['billing', '/api/billing/plans'], + ['telegram', '/api/health/takip'], + ['scheduler', '/api/health/scheduler'], + ['items', '/api/health/kalem'], + ['embedding', '/api/health/uyum'], + ['bik', '/api/stats'], + ['history', '/api/history/stats'], + ['resultsRss', '/api/feeds/sonuc.rss?limit=1'], + ['protectedRss', '/api/feeds/tenders.rss'], + ['manifest', '/manifest.webmanifest'], + ['serviceWorker', '/sw.js'], + ['ted', '/api/ted?mod=turkiye&since=20990101&limit=1'], + ['kik', '/api/kik/search?limit=1'], + ['okas', '/api/okas/ara?q=hemogram'], + ['mcpGuard', '/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }) }], +]) { + raw[name] = await request(path, options); +} + +const guardPaths = { + authSession: ['/api/auth/me'], + documents: ['/api/me/documents'], + documentText: ['/api/documents/text?ikn=2026%2F1634449'], + apiKeys: ['/api/me/apikeys'], + emailConfig: ['/api/notify/email/config'], + plan: ['/api/me/plan'], + subscriptions: ['/api/notify/subscriptions'], + sourceHistory: ['/api/tenders/source-history?ikn=2026%2F1634449'], + companyScan: ['/api/company/scan', { method: 'POST' }], + aiAnalyze: ['/api/ai/analyze', { method: 'POST' }], + checkout: ['/api/billing/checkout', { method: 'POST' }], + emailSend: ['/api/notify/email/test', { method: 'POST' }], + telegramMutation: ['/api/me/telegram', { method: 'POST' }], + notificationRun: ['/api/notify/run', { method: 'POST' }], +}; +const guards = {}; +for (const [name, [path, options]] of Object.entries(guardPaths)) { + const probe = await request(path, options); + guards[name] = { status: probe.status, passed: [401, 403].includes(probe.status) }; +} + +function row(id, label, status, evidence, remaining) { + return { id, label, status, evidence, remaining }; +} + +const googleConfigured = raw.google.status === 200 && Boolean(raw.google.json?.clientId); +const tcmbReady = raw.tcmb.status === 200 && raw.tcmb.json?.success === true + && Number(raw.tcmb.json?.rates?.USD) > 0 && Number(raw.tcmb.json?.rates?.EUR) > 0; +const aiConfigured = raw.ai.status === 200 && raw.ai.json?.configured === true; +const billingConfigured = raw.billing.status === 200 && raw.billing.json?.configured === true; +const billingWebhookReady = raw.billing.status === 200 && raw.billing.json?.webhookHazir === true; +const telegramConfigured = raw.telegram.status === 200 && raw.telegram.json?.telegram === true; +const kikManualGate = raw.kik.status === 428 && raw.kik.json?.code === 'HUMAN_VERIFICATION_REQUIRED'; +const ekapManualGate = raw.okas.status === 428 && raw.okas.json?.code === 'HUMAN_VERIFICATION_REQUIRED'; + +const integrations = [ + row('BIK', 'ilan.gov.tr ihale havuzu', raw.bik.status === 200 && raw.bik.json?.success ? 'passed' : 'blocked', { status: raw.bik.status, source: raw.bik.json?.kaynak || null, loaded: raw.bik.json?.yuklenenIlan ?? null }, 'Güncel liste okuması doğrulandı; veri kalitesi D10/D04 kapsamında izlenir.'), + row('TCMB', 'TCMB döviz satış kurları', tcmbReady ? 'passed' : 'blocked', { status: raw.tcmb.status, bulletinDate: raw.tcmb.json?.bulletinDate || null, source: raw.tcmb.json?.source || null, cached: raw.tcmb.json?.cached ?? null }, tcmbReady ? 'Hata ve eski bülten UX testleri yerel otomasyonda kapsanıyor.' : raw.tcmb.error), + row('GOOGLE', 'Google ile giriş', googleConfigured ? 'configured' : 'blocked', { status: raw.google.status, configured: googleConfigured }, 'Gerçek test hesabıyla giriş, çıkış ve oturum yenileme kabulü gerekli.'), + row('EKAP', 'EKAP arama ve resmi doküman', raw.okas.status === 200 ? 'partial' : ekapManualGate ? 'manual-gate' : 'blocked', { browserAvailable: raw.documents.json?.tarayiciVar ?? null, okasStatus: raw.okas.status, okasCode: raw.okas.json?.code || null, d10DocumentCode: 'HUMAN_VERIFICATION_REQUIRED' }, 'Yetkili kullanıcı güvenlik doğrulamasından sonra beş resmi paketi indirip yüklemeli.'), + row('KIK', 'KİK kurul kararları', raw.kik.status === 200 && raw.kik.json?.success ? 'passed' : kikManualGate ? 'manual-gate' : 'blocked', { status: raw.kik.status, code: raw.kik.json?.code || null, resultCount: raw.kik.json?.results?.length ?? null, error: raw.kik.error }, raw.kik.status === 200 ? 'Bir kararın ayrıntı bağlantısı ayrıca kullanıcı akışında açılmalı.' : kikManualGate ? 'EKAP güvenlik doğrulamasından sonra arama ve karar metni tekrar denenmeli.' : 'Kaynak erişimi düzeltildikten sonra arama ve karar metni tekrar denenmeli.'), + row('TED', 'TED Avrupa ihaleleri', raw.ted.status === 200 && raw.ted.json?.success ? 'passed' : 'blocked', { status: raw.ted.status, totalCount: raw.ted.json?.totalCount ?? null, timedOut: raw.ted.json?.timedOut ?? null, error: raw.ted.error }, raw.ted.status === 200 ? 'Gerçek sonuçlu sorgu ve çeviri, kontrollü AI test bütçesiyle yapılmalı.' : 'TED kaynağı tekrar denenmeli.'), + row('AI', 'AI analiz ve teklif yardımcıları', aiConfigured ? 'configured' : 'blocked', { status: raw.ai.status, configured: aiConfigured, providerCount: Array.isArray(raw.ai.json?.providers) ? raw.ai.json.providers.length : 0 }, 'Yetkili test hesabı ve incelemesi tamamlanmış test belgesiyle analiz, soru, taslak ve denetim zinciri gerekli.'), + row('STRIPE', 'Stripe plan, ödeme ve webhook', billingConfigured && billingWebhookReady ? 'configured' : 'blocked', { status: raw.billing.status, checkoutConfigured: billingConfigured, webhookConfigured: billingWebhookReady, planCount: Array.isArray(raw.billing.json?.plans) ? raw.billing.json.plans.length : 0 }, 'Yalnız Stripe test ortamında başarılı/başarısız ödeme, webhook tekrarı ve iptal senaryoları gerekli.'), + row('EMAIL', 'E-posta bildirimleri', guards.emailConfig?.passed ? 'guarded' : 'blocked', { unauthenticatedStatus: guards.emailConfig?.status ?? null }, 'Yetkili test hesabında yapılandırma okunmalı; izinli test alıcısına tek gönderim ayrıca onaylanmalı.'), + row('TELEGRAM', 'Telegram bağlantısı ve bildirim', telegramConfigured ? 'configured' : 'blocked', { status: raw.telegram.status, configured: telegramConfigured }, 'Test sohbetinde bağlama kodu ve tek test mesajı için açık gönderim yetkisi gerekli.'), + row('PWA', 'PWA manifest ve offline worker', raw.manifest.status === 200 && raw.serviceWorker.status === 200 ? 'passed' : 'blocked', { manifestStatus: raw.manifest.status, serviceWorkerStatus: raw.serviceWorker.status }, 'Gerçek cihazda kurulum, offline düzenleme ve yeniden bağlanma kabulü ayrıca izlenir.'), + row('API', 'API anahtarı, RSS ve MCP', raw.protectedRss.status === 401 && raw.mcpGuard.status === 401 && guards.apiKeys?.passed ? 'guarded' : 'blocked', { apiKeyGuard: guards.apiKeys?.status ?? null, rssGuard: raw.protectedRss.status, mcpGuard: raw.mcpGuard.status, publicResultsRssStatus: raw.resultsRss.status }, 'Yetkili test hesabında anahtar oluşturma, RSS/MCP kullanma ve iptal zinciri gerekli.'), + row('SCHEDULER', 'Zeyilname/sonuç zamanlayıcısı', raw.scheduler.status === 200 && raw.scheduler.json?.success ? 'observable' : 'blocked', { status: raw.scheduler.status, browserAvailable: raw.scheduler.json?.tarayiciVar ?? null, resultScanActive: raw.scheduler.json?.sonucTaramasi?.aktif ?? null, resultScanRunning: raw.scheduler.json?.sonucTaramasi?.calisiyor ?? null, directProcurementActive: raw.scheduler.json?.dogrudanTemin?.aktif ?? null, directProcurementRunning: raw.scheduler.json?.dogrudanTemin?.calisiyor ?? null }, 'Yetkili test kaydıyla zeyilname/sonuç olayı ve tekrar engeli doğrulanmalı.'), +]; + +const result = { + checkedAt: new Date().toISOString(), + base, + readOnly: true, + scope: 'Yapılandırma, public kaynak okuması ve kimlik doğrulama kapıları denetlendi; giriş, veri değişikliği, AI üretimi, ödeme, mesaj veya teklif gönderimi yapılmadı.', + integrations, + guards, + summary: { + total: integrations.length, + passed: integrations.filter(({ status }) => status === 'passed').length, + configured: integrations.filter(({ status }) => status === 'configured').length, + guarded: integrations.filter(({ status }) => status === 'guarded').length, + observable: integrations.filter(({ status }) => status === 'observable').length, + manualGate: integrations.filter(({ status }) => status === 'manual-gate').length, + blocked: integrations.filter(({ status }) => status === 'blocked').length, + protectedGuardsPassed: Object.values(guards).filter(({ passed }) => passed).length, + protectedGuardsTotal: Object.keys(guards).length, + }, +}; + +await mkdir(dirname(evidencePath), { recursive: true }); +await writeFile(evidencePath, `${JSON.stringify(result, null, 2)}\n`); +console.log(JSON.stringify({ checkedAt: result.checkedAt, ...result.summary, statuses: Object.fromEntries(integrations.map(({ id, status }) => [id, status])) })); diff --git a/tests/nginx-mcp-proxy.test.mjs b/tests/nginx-mcp-proxy.test.mjs new file mode 100644 index 0000000..bc3ce18 --- /dev/null +++ b/tests/nginx-mcp-proxy.test.mjs @@ -0,0 +1,10 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +test('Nginx proxies MCP JSON-RPC requests to the authenticated Node endpoint', () => { + const config = readFileSync(new URL('../nginx.conf', import.meta.url), 'utf8'); + const block = /location\s*=\s*\/mcp\s*\{([\s\S]*?)\n\s*\}/.exec(config)?.[1] || ''; + assert.match(block, /proxy_pass\s+http:\/\/127\.0\.0\.1:3001;/); + assert.match(block, /client_max_body_size\s+1m;/); +}); diff --git a/tests/source-error.test.mjs b/tests/source-error.test.mjs index e56fb00..2baac86 100644 --- a/tests/source-error.test.mjs +++ b/tests/source-error.test.mjs @@ -22,3 +22,10 @@ test('ordinary upstream failures stay retryable', () => { retryable: true, }); }); + +test('KIK HTTP 428 is also exposed as the same manual verification gate', () => { + const result = sourceFailure(new Error('KİK sunucusu isteği reddetti (HTTP 428).')); + assert.equal(result.status, 428); + assert.equal(result.body.code, 'HUMAN_VERIFICATION_REQUIRED'); + assert.equal(result.body.manualAction, true); +});