From 5b176851c829ec54894547c8d156c19122c2437a Mon Sep 17 00:00:00 2001 From: MrSphay Date: Sun, 17 May 2026 14:03:18 +0200 Subject: [PATCH] fix: classify acled auth diagnostics --- apis/sources/acled.mjs | 80 +++++++++++++++++------- docs/sources/acled.md | 5 +- test/fetch-utils.test.mjs | 126 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 23 deletions(-) diff --git a/apis/sources/acled.mjs b/apis/sources/acled.mjs index 38dbea4..a901175 100644 --- a/apis/sources/acled.mjs +++ b/apis/sources/acled.mjs @@ -15,6 +15,31 @@ const API_BASE = 'https://acleddata.com/api/acled/read'; // Session cache let sessionCache = { cookies: null, token: null, method: null, expires: 0 }; +function acledCredentials() { + return { + email: process.env.ACLED_EMAIL || process.env.ACLED_USER || process.env.ACLED_USERNAME, + password: process.env.ACLED_PASSWORD, + }; +} + +function sanitizeAuthHeaders(headers) { + const safe = { ...headers }; + if (safe.Authorization) safe.Authorization = 'Bearer [redacted]'; + if (safe.Cookie) safe.Cookie = '[redacted]'; + return safe; +} + +function classifyAuthFailure(status, body = '') { + if (status === 401 || status === 403) return 'auth_denied'; + if (status >= 500) return 'auth_endpoint_failed'; + if (/invalid|denied|unauthorized|forbidden/i.test(body)) return 'auth_denied'; + return 'auth_endpoint_failed'; +} + +export function resetAcledSessionForTests() { + sessionCache = { cookies: null, token: null, method: null, expires: 0 }; +} + // Strategy 1: Cookie-based session login (mirrors browser login) async function loginCookie(email, password) { const controller = new AbortController(); @@ -43,11 +68,14 @@ async function loginCookie(email, password) { } const errText = await res.text().catch(() => ''); - return { error: `Cookie login failed (HTTP ${res.status}): ${errText.slice(0, 200)}` }; + return { + error: `Cookie login failed (HTTP ${res.status}): ${errText.slice(0, 200)}`, + code: classifyAuthFailure(res.status, errText), + }; } catch (e) { clearTimeout(timer); const cause = e.cause ? ` → ${e.cause.message || e.cause.code || e.cause}` : ''; - return { error: `Cookie login error: ${e.message}${cause}` }; + return { error: `Cookie login error: ${e.message}${cause}`, code: 'auth_endpoint_failed' }; } } @@ -73,28 +101,30 @@ async function loginOAuth(email, password) { if (!res.ok) { const errText = await res.text().catch(() => ''); - return { error: `OAuth failed (HTTP ${res.status}): ${errText.slice(0, 200)}` }; + return { + error: `OAuth failed (HTTP ${res.status}): ${errText.slice(0, 200)}`, + code: classifyAuthFailure(res.status, errText), + }; } const data = await res.json(); if (!data.access_token) { - return { error: `OAuth response missing access_token: ${JSON.stringify(data).slice(0, 200)}` }; + return { error: `OAuth response missing access_token: ${JSON.stringify(data).slice(0, 200)}`, code: 'auth_endpoint_failed' }; } return { token: data.access_token }; } catch (e) { clearTimeout(timer); const cause = e.cause ? ` → ${e.cause.message || e.cause.code || e.cause}` : ''; - return { error: `OAuth error: ${e.message}${cause}` }; + return { error: `OAuth error: ${e.message}${cause}`, code: 'auth_endpoint_failed' }; } } // Try both auth strategies async function authenticate() { - const email = process.env.ACLED_EMAIL; - const password = process.env.ACLED_PASSWORD; + const { email, password } = acledCredentials(); if (!email || !password) { - return { error: 'No ACLED credentials. Set ACLED_EMAIL and ACLED_PASSWORD in .env.' }; + return { error: 'No ACLED credentials. Set ACLED_EMAIL or ACLED_USER and ACLED_PASSWORD in .env.', code: 'no_credentials' }; } // Return cached session if still valid @@ -108,7 +138,7 @@ async function authenticate() { // Try OAuth first (official programmatic method per ACLED docs) const oauthResult = await loginOAuth(email, password); if (oauthResult.token) { - if (debug) console.error(`[ACLED DEBUG] OAuth OK — token: ${oauthResult.token.slice(0, 20)}...`); + if (debug) console.error('[ACLED DEBUG] OAuth OK'); sessionCache = { cookies: null, token: oauthResult.token, method: 'oauth', expires: Date.now() + 23 * 60 * 60 * 1000 }; return sessionCache; } @@ -118,13 +148,14 @@ async function authenticate() { // Fall back to cookie-based session const cookieResult = await loginCookie(email, password); if (cookieResult.cookies) { - if (debug) console.error(`[ACLED DEBUG] Cookie OK — cookies: ${cookieResult.cookies.slice(0, 80)}...`); + if (debug) console.error('[ACLED DEBUG] Cookie OK'); sessionCache = { cookies: cookieResult.cookies, token: null, method: 'cookie', expires: Date.now() + 12 * 60 * 60 * 1000 }; return sessionCache; } errors.push(`Cookie: ${cookieResult.error}`); - return { error: `All ACLED auth methods failed.\n${errors.join('\n')}` }; + const code = [oauthResult.code, cookieResult.code].includes('auth_denied') ? 'auth_denied' : 'auth_endpoint_failed'; + return { error: `All ACLED auth methods failed.\n${errors.join('\n')}`, code }; } // Build headers based on auth method @@ -160,7 +191,7 @@ export async function getEvents(opts = {}) { } = opts; const session = await authenticate(); - if (session.error) return { error: session.error }; + if (session.error) return { error: session.error, code: session.code || 'auth_failed' }; const params = new URLSearchParams({ _format: 'json', limit: String(limit) }); if (eventDateStart && eventDateEnd) { @@ -177,7 +208,7 @@ export async function getEvents(opts = {}) { const hdrs = authHeaders(session); if (debug) { console.error(`[ACLED DEBUG] Data request: GET ${url}`); - console.error(`[ACLED DEBUG] Headers: ${JSON.stringify(hdrs)}`); + console.error(`[ACLED DEBUG] Headers: ${JSON.stringify(sanitizeAuthHeaders(hdrs))}`); } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 25000); @@ -201,25 +232,28 @@ export async function getEvents(opts = {}) { + ' 3. Ensure your account has the "API" access group\n' + ' Contact access@acleddata.com if issues persist.' : ''; - return { error: `ACLED data access denied (HTTP ${res.status}, auth method: ${session.method}). Response: ${errText.slice(0, 300)}${hint}` }; + return { + error: `ACLED data access denied (HTTP ${res.status}, auth method: ${session.method}). Response: ${errText.slice(0, 300)}${hint}`, + code: 'auth_denied', + }; } - return { error: `HTTP ${res.status}: ${errText.slice(0, 200)}` }; + return { error: `HTTP ${res.status}: ${errText.slice(0, 200)}`, code: 'api_failed' }; } const data = await res.json(); // ACLED may return a 200 with an error status in the body if (data?.status && data.status !== 200) { - return { error: `ACLED API error: status ${data.status} — ${data.message || 'Unknown error'}` }; + return { error: `ACLED API error: status ${data.status} - ${data.message || 'Unknown error'}`, code: 'api_failed' }; } return data; } catch (e) { if (e.name === 'AbortError') { - return { error: 'ACLED data request timed out (25s)' }; + return { error: 'ACLED data request timed out (25s)', code: 'api_failed' }; } const rootCause = e.cause ? `${e.cause.message || e.cause.code || e.cause}` : ''; - return { error: `ACLED data error: ${e.message}${rootCause ? ' → ' + rootCause : ''}` }; + return { error: `ACLED data error: ${e.message}${rootCause ? ' - ' + rootCause : ''}`, code: 'api_failed' }; } } @@ -237,12 +271,13 @@ function groupBy(events, field) { // Briefing — last 7 days of global conflict events export async function briefing() { - if (!process.env.ACLED_EMAIL || !process.env.ACLED_PASSWORD) { + const { email, password } = acledCredentials(); + if (!email || !password) { return { source: 'ACLED', timestamp: new Date().toISOString(), status: 'no_credentials', - message: 'Set ACLED_EMAIL and ACLED_PASSWORD in .env. Register at https://acleddata.com/user/register', + message: 'Set ACLED_EMAIL or ACLED_USER and ACLED_PASSWORD in .env. Register at https://acleddata.com/user/register', }; } @@ -256,7 +291,8 @@ export async function briefing() { }); if (data?.error) { - return { source: 'ACLED', timestamp: new Date().toISOString(), error: data.error }; + const status = data.code === 'auth_denied' ? 'auth_failed' : 'api_failed'; + return { source: 'ACLED', timestamp: new Date().toISOString(), status, error: data.error }; } let events = data?.data || []; @@ -300,6 +336,7 @@ export async function briefing() { return { source: 'ACLED', timestamp: new Date().toISOString(), + status: 'live', period: { start, end }, totalEvents: events.length, totalFatalities, @@ -307,6 +344,7 @@ export async function briefing() { byType, topCountries, deadliestEvents, + message: events.length === 0 ? 'ACLED returned a valid empty event set for the requested period.' : undefined, }; } diff --git a/docs/sources/acled.md b/docs/sources/acled.md index c6ba1fb..62518ec 100644 --- a/docs/sources/acled.md +++ b/docs/sources/acled.md @@ -2,8 +2,9 @@ Provides conflict events, fatalities, event types, and locations. -- Auth: `ACLED_EMAIL` and `ACLED_PASSWORD`. +- Auth: `ACLED_EMAIL` or `ACLED_USER`, plus `ACLED_PASSWORD`. - Flow: OAuth password grant is tried first, then cookie session fallback. -- Failure modes: missing credentials, terms/profile not completed, expired token, account missing API access. +- Failure modes: missing credentials (`no_credentials`), rejected credentials or access denied (`auth_failed`), token/API endpoint failure (`api_failed`), and valid empty event sets (`totalEvents: 0`). - Behavior: missing or rejected credentials produce degraded source health with the ACLED error text. +- Debug logs redact bearer tokens and cookies. - Test: set credentials, run `node apis/sources/acled.mjs`, then check `/api/health`. diff --git a/test/fetch-utils.test.mjs b/test/fetch-utils.test.mjs index 2dcee45..8f43c08 100644 --- a/test/fetch-utils.test.mjs +++ b/test/fetch-utils.test.mjs @@ -34,3 +34,129 @@ test('safeFetchText returns text and byte count', async () => { globalThis.fetch = originalFetch; } }); + +function jsonResponse(payload, ok = true, status = 200) { + return { + ok, + status, + headers: { getSetCookie: () => [], get: () => 'application/json' }, + text: async () => JSON.stringify(payload), + json: async () => payload, + }; +} + +function textResponse(text, ok = false, status = 500) { + return { + ok, + status, + headers: { getSetCookie: () => [], get: () => 'text/plain' }, + text: async () => text, + json: async () => JSON.parse(text), + }; +} + +async function withAcledEnv(mockFetch, fn) { + const originalFetch = globalThis.fetch; + const saved = { + ACLED_EMAIL: process.env.ACLED_EMAIL, + ACLED_USER: process.env.ACLED_USER, + ACLED_USERNAME: process.env.ACLED_USERNAME, + ACLED_PASSWORD: process.env.ACLED_PASSWORD, + }; + globalThis.fetch = mockFetch; + delete process.env.ACLED_EMAIL; + delete process.env.ACLED_USER; + delete process.env.ACLED_USERNAME; + delete process.env.ACLED_PASSWORD; + const acled = await import('../apis/sources/acled.mjs'); + acled.resetAcledSessionForTests(); + try { + return await fn(acled); + } finally { + globalThis.fetch = originalFetch; + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + acled.resetAcledSessionForTests(); + } +} + +test('ACLED credentialed OAuth success returns live events and supports ACLED_USER', async () => { + const responses = [ + jsonResponse({ access_token: 'secret-token' }), + jsonResponse({ + status: 200, + data: [{ + event_date: '2026-05-17', + event_type: 'Protests', + sub_event_type: 'Peaceful protest', + country: 'Example', + region: 'Example Region', + location: 'Example City', + fatalities: '0', + latitude: '1.23', + longitude: '4.56', + }], + }), + ]; + + await withAcledEnv(async () => responses.shift(), async ({ briefing }) => { + process.env.ACLED_USER = 'operator@example.test'; + process.env.ACLED_PASSWORD = 'password'; + const data = await briefing(); + + assert.equal(data.status, 'live'); + assert.equal(data.totalEvents, 1); + assert.equal(data.topCountries.Example.count, 1); + }); +}); + +test('ACLED rejected credentials return auth_failed diagnostics', async () => { + const responses = [ + textResponse('invalid credentials', false, 401), + textResponse('forbidden', false, 403), + ]; + + await withAcledEnv(async () => responses.shift(), async ({ briefing }) => { + process.env.ACLED_EMAIL = 'operator@example.test'; + process.env.ACLED_PASSWORD = 'wrong-password'; + const data = await briefing(); + + assert.equal(data.status, 'auth_failed'); + assert.match(data.error, /All ACLED auth methods failed/); + }); +}); + +test('ACLED token endpoint failure returns api_failed diagnostics', async () => { + const responses = [ + textResponse('temporary outage', false, 503), + textResponse('temporary outage', false, 503), + ]; + + await withAcledEnv(async () => responses.shift(), async ({ briefing }) => { + process.env.ACLED_EMAIL = 'operator@example.test'; + process.env.ACLED_PASSWORD = 'password'; + const data = await briefing(); + + assert.equal(data.status, 'api_failed'); + assert.match(data.error, /All ACLED auth methods failed/); + }); +}); + +test('ACLED valid empty response is live with zero events', async () => { + const responses = [ + jsonResponse({ access_token: 'secret-token' }), + jsonResponse({ status: 200, data: [] }), + ]; + + await withAcledEnv(async () => responses.shift(), async ({ briefing }) => { + process.env.ACLED_EMAIL = 'operator@example.test'; + process.env.ACLED_PASSWORD = 'password'; + const data = await briefing(); + + assert.equal(data.status, 'live'); + assert.equal(data.totalEvents, 0); + assert.match(data.message, /valid empty/); + }); +}); -- 2.49.1