import { test } from 'node:test'; import assert from 'node:assert/strict'; import type { PluginNetCapability, PluginNetResponse } from '../types/openwa'; import { LibreTranslateClient } from './libretranslate.client.ts'; function res(partial: { ok?: boolean; status?: number; body?: unknown }): PluginNetResponse { return { ok: partial.ok ?? true, status: partial.status ?? 200, statusText: '', headers: {}, // The sandbox runtime returns the response body as a string and provides NO .json() (functions // can't cross the worker structuredClone boundary). The client must JSON.parse(res.body). body: partial.body === undefined ? '{}' : JSON.stringify(partial.body), }; } /** A programmable fake of ctx.net: each call shifts the next queued handler. */ function fakeNet(handlers: Array<(url: string) => Promise>) { const calls: string[] = []; const net: PluginNetCapability = { fetch: async (url: string) => { calls.push(url); const h = handlers.shift(); if (!h) throw new Error('no more handlers'); return h(url); }, }; return { net, calls }; } test('translate posts and returns translatedText on success', async () => { const { net, calls } = fakeNet([async () => res({ body: { translatedText: 'hola' } })]); const c = new LibreTranslateClient({ url: 'http://lt:7001/', timeoutMs: 4000, net }); assert.equal(await c.translate('hi', 'en', 'es'), 'hola'); assert.equal(calls[0], 'http://lt:7001/translate'); // trailing slash trimmed }); test('translate throws when the response lacks a translatedText string', async () => { const { net } = fakeNet([async () => res({ body: {} })]); // partial/empty body const c = new LibreTranslateClient({ url: 'http://lt:7001', timeoutMs: 4000, net }); await assert.rejects(c.translate('hi', 'en', 'es'), /translatedText/); }); test('detect returns the top result', async () => { const { net } = fakeNet([async () => res({ body: [{ language: 'en', confidence: 0.9 }] })]); const c = new LibreTranslateClient({ url: 'http://lt:7001', timeoutMs: 4000, net }); assert.deepEqual(await c.detect('hello'), { lang: 'en', confidence: 0.9 }); }); test('a non-ok status throws with the HTTP status', async () => { const { net } = fakeNet([async () => res({ ok: false, status: 502 })]); const c = new LibreTranslateClient({ url: 'http://lt:7001', timeoutMs: 4000, net }); await assert.rejects(c.translate('hi', 'en', 'es'), /HTTP 502/); }); test('opens the circuit after the failure threshold and short-circuits the next call', async () => { const fail = async () => { throw new Error('boom'); }; const { net, calls } = fakeNet([fail, fail]); const c = new LibreTranslateClient({ url: 'http://lt:7001', timeoutMs: 4000, net, failureThreshold: 2 }); await assert.rejects(c.translate('a', 'en', 'es'), /boom/); await assert.rejects(c.translate('b', 'en', 'es'), /boom/); assert.equal(c.isHealthy(), false); // circuit now open → next call throws WITHOUT hitting the network await assert.rejects(c.translate('c', 'en', 'es'), /circuit open/); assert.equal(calls.length, 2); // the 3rd call never reached fetch }); test('a success resets the consecutive-failure counter', async () => { const fail = async () => { throw new Error('boom'); }; const ok = async () => res({ body: { translatedText: 'x' } }); const { net } = fakeNet([fail, ok]); const c = new LibreTranslateClient({ url: 'http://lt:7001', timeoutMs: 4000, net, failureThreshold: 3 }); await assert.rejects(c.translate('a', 'en', 'es')); await c.translate('b', 'en', 'es'); assert.equal(c.isHealthy(), true); }); test('host concurrency rejections do not increment the circuit breaker counter', async () => { const rejectConcurrent = async () => { throw new Error('Plugin group-translate has too many concurrent capability calls'); }; const ok = async () => res({ body: { translatedText: 'hola' } }); const { net } = fakeNet([rejectConcurrent, rejectConcurrent, ok]); const c = new LibreTranslateClient({ url: 'http://lt:7001', timeoutMs: 4000, net, failureThreshold: 2 }); await assert.rejects(c.translate('a', 'en', 'es'), /too many concurrent/i); await assert.rejects(c.translate('b', 'en', 'es'), /too many concurrent/i); assert.equal(c.isHealthy(), true); assert.equal(await c.translate('c', 'en', 'es'), 'hola'); }); test('a host fetch refusal is rethrown without the URL credentials it quotes', async () => { // The host names the full request URL when it refuses a fetch, and a credentialed // `libretranslateUrl` is dropped from the config-derived allowlist, so that refusal fires on EVERY // message. The coordinator logs the rejection reason verbatim, so an unredacted rethrow wrote the // operator's password into the host log once per message. const { net } = fakeNet([ async () => { throw new Error( 'Plugin group-translate may not fetch https://admin:s3cr3t@lt.example.com/translate ' + '- add its host to net.allow or net.allowConfigHosts', ); }, ]); const client = new LibreTranslateClient({ url: 'https://admin:s3cr3t@lt.example.com', timeoutMs: 1000, net }); const err = await client.translate('hi', 'en', 'es').then( () => null, (e: unknown) => e as Error, ); assert.ok(err, 'the call must still fail'); assert.ok(!/s3cr3t/.test(err.message), `password leaked: ${err.message}`); assert.ok(!/admin:/.test(err.message), `userinfo leaked: ${err.message}`); // Still diagnosable: the host, the reason and the remedy survive. assert.match(err.message, /lt\.example\.com/); assert.match(err.message, /net\.allow/); }); test('a password containing @ is redacted whole, not just up to its first @', async () => { // `@` in a password is common and legal. Stopping the match at the first one republished the tail: // `admin:p@ssw0rd@host` came out as `***:***@ssw0rd@host`, still carrying most of the secret. const { net } = fakeNet([ async () => { throw new Error('Plugin group-translate may not fetch https://admin:p@ssw0rd@lt.example.com/translate - add its host'); }, ]); const client = new LibreTranslateClient({ url: 'https://admin:p@ssw0rd@lt.example.com', timeoutMs: 1000, net }); const err = (await client.translate('hi', 'en', 'es').then(() => null, (e: unknown) => e as Error))!; assert.ok(!/ssw0rd/.test(err.message), `password tail leaked: ${err.message}`); assert.match(err.message, /\/\/\*\*\*:\*\*\*@lt\.example\.com/); });