fix: AI translation security, error handling, and test coverage

- Add userId verification in getAITranslationResultAction (security)
- Use OperationNotAllowedError for auth failures
- Store failure marker in cache on last BullMQ attempt
- Make JSON parsing more robust (extract first {...} block)
- Add "keep modal open" hint to translating toast
- Add test coverage for process-ai-translation-job

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dhruwang
2026-04-22 15:18:54 +05:30
parent 14dcded91b
commit 2451acb9bd
20 changed files with 174 additions and 20 deletions
+1 -1
View File
@@ -2638,7 +2638,7 @@ checksums:
workspace/surveys/edit/ai_instance_not_configured: 939ad7c3240fa8de98a325239f1b36bc
workspace/surveys/edit/ai_smart_tools_disabled: 13df84ae47d35dfa6e86ffa62f29c75d
workspace/surveys/edit/ai_translate: f25943cdeffe155ee524428f4daa5da2
workspace/surveys/edit/ai_translating: fce4992a1d766124c3e25ef279785a2b
workspace/surveys/edit/ai_translating: 098a2293b39f9f258d67f926cf03df37
workspace/surveys/edit/ai_translation_all_fields_populated: d78f6a663ea19ce77045970179bd200f
workspace/surveys/edit/ai_translation_complete: f443d0801404f728e68000b46ca67598
workspace/surveys/edit/ai_translation_failed: fd356a173d0abde7a0fc660394954cc7
+1 -1
View File
@@ -2757,7 +2757,7 @@
"ai_instance_not_configured": "KI ist nicht konfiguriert. Kontaktiere deinen Administrator.",
"ai_smart_tools_disabled": "KI-Smart-Tools sind für diese Organisation deaktiviert.",
"ai_translate": "Mit KI übersetzen",
"ai_translating": "Übersetze mit KI...",
"ai_translating": "Übersetze mit KI... Bitte lasse dieses Fenster geöffnet.",
"ai_translation_all_fields_populated": "Alle Felder sind bereits übersetzt",
"ai_translation_complete": "KI-Übersetzung abgeschlossen",
"ai_translation_failed": "Übersetzung fehlgeschlagen",
+1 -1
View File
@@ -2757,7 +2757,7 @@
"ai_instance_not_configured": "AI is not configured. Contact your administrator.",
"ai_smart_tools_disabled": "AI smart tools are disabled for this organization.",
"ai_translate": "Translate with AI",
"ai_translating": "Translating with AI...",
"ai_translating": "Translating with AI... Please keep this modal open.",
"ai_translation_all_fields_populated": "All fields are already translated",
"ai_translation_complete": "AI translation complete",
"ai_translation_failed": "Translation failed",
+1 -1
View File
@@ -2757,7 +2757,7 @@
"ai_instance_not_configured": "La IA no está configurada. Contacta con tu administrador.",
"ai_smart_tools_disabled": "Las herramientas inteligentes de IA están deshabilitadas para esta organización.",
"ai_translate": "Traducir con IA",
"ai_translating": "Traduciendo con IA...",
"ai_translating": "Traduciendo con IA... Por favor, mantén este modal abierto.",
"ai_translation_all_fields_populated": "Todos los campos ya están traducidos",
"ai_translation_complete": "Traducción con IA completada",
"ai_translation_failed": "La traducción ha fallado",
+1 -1
View File
@@ -2757,7 +2757,7 @@
"ai_instance_not_configured": "L'IA n'est pas configurée. Contacte ton administrateur.",
"ai_smart_tools_disabled": "Les outils intelligents IA sont désactivés pour cette organisation.",
"ai_translate": "Traduire avec l'IA",
"ai_translating": "Traduction avec l'IA en cours...",
"ai_translating": "Traduction en cours avec l'IA... Garde cette fenêtre ouverte.",
"ai_translation_all_fields_populated": "Tous les champs sont déjà traduits",
"ai_translation_complete": "Traduction IA terminée",
"ai_translation_failed": "La traduction a échoué",
+1 -1
View File
@@ -2757,7 +2757,7 @@
"ai_instance_not_configured": "Az AI nincs konfigurálva. Kérjük, forduljon a rendszergazdájához.",
"ai_smart_tools_disabled": "Az AI intelligens eszközök le vannak tiltva ezen szervezet számára.",
"ai_translate": "Fordítás mesterséges intelligenciával",
"ai_translating": "Fordítás mesterséges intelligenciával...",
"ai_translating": "AI fordítás folyamatban... Kérjük, tartsa nyitva ezt az ablakot.",
"ai_translation_all_fields_populated": "Minden mező már le van fordítva",
"ai_translation_complete": "A mesterséges intelligencia által végzett fordítás befejeződött",
"ai_translation_failed": "A fordítás sikertelen volt",
+1 -1
View File
@@ -2757,7 +2757,7 @@
"ai_instance_not_configured": "AIが設定されていません。管理者にお問い合わせください。",
"ai_smart_tools_disabled": "この組織ではAIスマートツールが無効になっています。",
"ai_translate": "AIで翻訳",
"ai_translating": "AIで翻訳中...",
"ai_translating": "AIで翻訳中... このモーダルを開いたままにしてください。",
"ai_translation_all_fields_populated": "すべてのフィールドは既に翻訳されています",
"ai_translation_complete": "AI翻訳が完了しました",
"ai_translation_failed": "翻訳に失敗しました",
+1 -1
View File
@@ -2757,7 +2757,7 @@
"ai_instance_not_configured": "AI is niet geconfigureerd. Neem contact op met je beheerder.",
"ai_smart_tools_disabled": "AI slimme tools zijn uitgeschakeld voor deze organisatie.",
"ai_translate": "Vertalen met AI",
"ai_translating": "Bezig met vertalen via AI...",
"ai_translating": "Vertalen met AI... Laat dit venster open.",
"ai_translation_all_fields_populated": "Alle velden zijn al vertaald",
"ai_translation_complete": "AI-vertaling voltooid",
"ai_translation_failed": "Vertaling mislukt",
+1 -1
View File
@@ -2757,7 +2757,7 @@
"ai_instance_not_configured": "A IA não está configurada. Entre em contato com seu administrador.",
"ai_smart_tools_disabled": "As ferramentas inteligentes de IA estão desabilitadas para esta organização.",
"ai_translate": "Traduzir com IA",
"ai_translating": "Traduzindo com IA...",
"ai_translating": "Traduzindo com IA... Por favor, mantenha este modal aberto.",
"ai_translation_all_fields_populated": "Todos os campos já estão traduzidos",
"ai_translation_complete": "Tradução com IA concluída",
"ai_translation_failed": "Falha na tradução",
+1 -1
View File
@@ -2757,7 +2757,7 @@
"ai_instance_not_configured": "A IA não está configurada. Contacta o teu administrador.",
"ai_smart_tools_disabled": "As ferramentas inteligentes de IA estão desativadas para esta organização.",
"ai_translate": "Traduzir com IA",
"ai_translating": "A traduzir com IA...",
"ai_translating": "A traduzir com IA... Mantém esta janela aberta, por favor.",
"ai_translation_all_fields_populated": "Todos os campos já estão traduzidos",
"ai_translation_complete": "Tradução com IA concluída",
"ai_translation_failed": "A tradução falhou",
+1 -1
View File
@@ -2757,7 +2757,7 @@
"ai_instance_not_configured": "AI nu este configurat. Contactează administratorul.",
"ai_smart_tools_disabled": "Instrumentele inteligente AI sunt dezactivate pentru această organizație.",
"ai_translate": "Traduce cu AI",
"ai_translating": "Se traduce cu AI...",
"ai_translating": "Se traduce cu AI... Te rugăm să ții această fereastră deschisă.",
"ai_translation_all_fields_populated": "Toate câmpurile sunt deja traduse",
"ai_translation_complete": "Traducerea AI finalizată",
"ai_translation_failed": "Traducerea a eșuat",
+1 -1
View File
@@ -2757,7 +2757,7 @@
"ai_instance_not_configured": "ИИ не настроен. Свяжись с администратором.",
"ai_smart_tools_disabled": "Умные инструменты ИИ отключены для этой организации.",
"ai_translate": "Перевести с помощью ИИ",
"ai_translating": "Перевод с помощью ИИ...",
"ai_translating": "Перевод с помощью ИИ... Пожалуйста, не закрывай это окно.",
"ai_translation_all_fields_populated": "Все поля уже переведены",
"ai_translation_complete": "Перевод с помощью ИИ завершён",
"ai_translation_failed": "Перевод не удался",
+1 -1
View File
@@ -2757,7 +2757,7 @@
"ai_instance_not_configured": "AI är inte konfigurerad. Kontakta din administratör.",
"ai_smart_tools_disabled": "AI smarta verktyg är inaktiverade för den här organisationen.",
"ai_translate": "Översätt med AI",
"ai_translating": "Översätter med AI...",
"ai_translating": "Översätter med AI... Vänligen håll denna dialogruta öppen.",
"ai_translation_all_fields_populated": "Alla fält är redan översatta",
"ai_translation_complete": "AI-översättning klar",
"ai_translation_failed": "Översättningen misslyckades",
+1 -1
View File
@@ -2757,7 +2757,7 @@
"ai_instance_not_configured": "Yapay zeka yapılandırılmamış. Yöneticinle iletişime geç.",
"ai_smart_tools_disabled": "Bu organizasyon için yapay zeka akıllı araçları devre dışı.",
"ai_translate": "Yapay Zeka ile Çevir",
"ai_translating": "Yapay Zeka ile çevriliyor...",
"ai_translating": "Yapay zeka ile çevriliyor... Lütfen bu pencereyi açık tutun.",
"ai_translation_all_fields_populated": "Tüm alanlar zaten çevrilmiş",
"ai_translation_complete": "Yapay Zeka çevirisi tamamlandı",
"ai_translation_failed": "Çeviri başarısız oldu",
+1 -1
View File
@@ -2757,7 +2757,7 @@
"ai_instance_not_configured": "AI 未配置。请联系您的管理员。",
"ai_smart_tools_disabled": "此组织已禁用 AI 智能工具。",
"ai_translate": "使用 AI 翻译",
"ai_translating": "正在使用 AI 翻译...",
"ai_translating": "AI 翻译...请保持此窗口打开。",
"ai_translation_all_fields_populated": "所有字段均已翻译",
"ai_translation_complete": "AI 翻译完成",
"ai_translation_failed": "翻译失败",
+1 -1
View File
@@ -2757,7 +2757,7 @@
"ai_instance_not_configured": "AI 未設定。請聯絡您的管理員。",
"ai_smart_tools_disabled": "此組織已停用 AI 智慧工具。",
"ai_translate": "使用 AI 翻譯",
"ai_translating": "AI 翻譯...",
"ai_translating": "正在使用 AI 翻譯...請保持此視窗開啟。",
"ai_translation_all_fields_populated": "所有欄位都已翻譯",
"ai_translation_complete": "AI 翻譯完成",
"ai_translation_failed": "翻譯失敗",
@@ -3,6 +3,7 @@
import { z } from "zod";
import { enqueueAITranslationJob } from "@formbricks/jobs";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { assertOrganizationAIConfigured } from "@/lib/ai/service";
import { cache } from "@/lib/cache";
import { authenticatedActionClient } from "@/lib/utils/action-client";
@@ -119,7 +120,7 @@ export const getAITranslationResultAction = authenticatedActionClient
// Verify the requesting user owns this translation result
if (result.data.userId !== ctx.user.id) {
throw new Error("Not authorized");
throw new OperationNotAllowedError("Not authorized");
}
// Check if the job failed
@@ -0,0 +1,143 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { JobExecutionContext } from "@formbricks/jobs";
const mocks = vi.hoisted(() => ({
generateOrganizationAIText: vi.fn(),
cacheSet: vi.fn(),
loggerInfo: vi.fn(),
loggerError: vi.fn(),
}));
vi.mock("server-only", () => ({}));
vi.mock("@/lib/ai/service", () => ({ generateOrganizationAIText: mocks.generateOrganizationAIText }));
vi.mock("@/lib/cache", () => ({ cache: { set: mocks.cacheSet } }));
vi.mock("@formbricks/logger", () => ({
logger: { info: mocks.loggerInfo, error: mocks.loggerError },
}));
const makeData = () => ({
organizationId: "org_1",
workspaceId: "ws_1",
userId: "user_1",
fields: [
{ path: "q1.headline", defaultText: "Hello", isRichText: false },
{ path: "q1.subheader", defaultText: "World", isRichText: true },
],
sourceLanguage: "English",
targetLanguage: "German",
});
const makeContext = (overrides?: Partial<JobExecutionContext>): JobExecutionContext => ({
attempt: 1,
jobId: "42",
jobName: "ai-translation.translate",
maxAttempts: 3,
queueName: "background-jobs",
...overrides,
});
describe("processAITranslationJob", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.cacheSet.mockResolvedValue({ ok: true });
});
test("parses AI response, filters to valid keys, and caches result", async () => {
mocks.generateOrganizationAIText.mockResolvedValue({
text: '{"q1.headline":"Hallo","q1.subheader":"Welt","extra":"ignored"}',
});
const { processAITranslationJob } = await import("./process-ai-translation-job");
await processAITranslationJob(makeData(), makeContext());
expect(mocks.cacheSet).toHaveBeenCalledWith(
"ai-translation-result:42",
{ userId: "user_1", translations: { "q1.headline": "Hallo", "q1.subheader": "Welt" } },
300_000
);
expect(mocks.loggerInfo).toHaveBeenCalled();
});
test("extracts JSON from markdown code fences", async () => {
mocks.generateOrganizationAIText.mockResolvedValue({
text: '```json\n{"q1.headline":"Hallo"}\n```',
});
const { processAITranslationJob } = await import("./process-ai-translation-job");
await processAITranslationJob(makeData(), makeContext());
expect(mocks.cacheSet).toHaveBeenCalledWith(
"ai-translation-result:42",
expect.objectContaining({ translations: expect.objectContaining({ "q1.headline": "Hallo" }) }),
300_000
);
});
test("throws when AI response contains no JSON object", async () => {
mocks.generateOrganizationAIText.mockResolvedValue({ text: "no json here" });
const { processAITranslationJob } = await import("./process-ai-translation-job");
await expect(processAITranslationJob(makeData(), makeContext())).rejects.toThrow(
"Failed to parse AI translation response"
);
});
test("throws when cache.set fails on success path", async () => {
mocks.generateOrganizationAIText.mockResolvedValue({ text: '{"q1.headline":"Hallo"}' });
mocks.cacheSet.mockResolvedValue({ ok: false, error: { code: "RedisConnectionError" } });
const { processAITranslationJob } = await import("./process-ai-translation-job");
await expect(processAITranslationJob(makeData(), makeContext())).rejects.toThrow(
"Failed to store AI translation result in cache"
);
});
test("stores failure marker on last attempt", async () => {
const aiError = new Error("ai_features_not_enabled");
mocks.generateOrganizationAIText.mockRejectedValue(aiError);
const { processAITranslationJob } = await import("./process-ai-translation-job");
await expect(
processAITranslationJob(makeData(), makeContext({ attempt: 3, maxAttempts: 3 }))
).rejects.toThrow(aiError);
expect(mocks.cacheSet).toHaveBeenCalledWith(
"ai-translation-result:42",
{ userId: "user_1", error: "ai_features_not_enabled" },
300_000
);
});
test("does not store failure marker on non-last attempt", async () => {
mocks.generateOrganizationAIText.mockRejectedValue(new Error("transient"));
const { processAITranslationJob } = await import("./process-ai-translation-job");
await expect(
processAITranslationJob(makeData(), makeContext({ attempt: 1, maxAttempts: 3 }))
).rejects.toThrow("transient");
expect(mocks.cacheSet).not.toHaveBeenCalled();
});
test("logs error when failure marker cache.set fails", async () => {
mocks.generateOrganizationAIText.mockRejectedValue(new Error("boom"));
mocks.cacheSet.mockResolvedValue({ ok: false, error: { code: "RedisConnectionError" } });
const { processAITranslationJob } = await import("./process-ai-translation-job");
await expect(
processAITranslationJob(makeData(), makeContext({ attempt: 3, maxAttempts: 3 }))
).rejects.toThrow("boom");
expect(mocks.loggerError).toHaveBeenCalledWith(
{ jobId: "42" },
"Failed to store AI translation failure marker in cache"
);
});
});
describe("getAITranslationCacheKey", () => {
test("returns prefixed key", async () => {
const { getAITranslationCacheKey } = await import("./process-ai-translation-job");
expect(getAITranslationCacheKey("99")).toBe("ai-translation-result:99");
});
});
@@ -62,7 +62,14 @@ Rules:
}
// Store result in Redis for the polling action to pick up
await cache.set(cacheKey, { userId: data.userId, translations }, AI_TRANSLATION_RESULT_TTL_MS);
const cacheResult = await cache.set(
cacheKey,
{ userId: data.userId, translations },
AI_TRANSLATION_RESULT_TTL_MS
);
if (!cacheResult.ok) {
throw new Error("Failed to store AI translation result in cache");
}
logger.info(
{
@@ -77,11 +84,14 @@ Rules:
// On the last retry attempt, store a failure marker so the client
// stops polling instead of spinning until the 2-minute timeout.
if (isLastAttempt) {
await cache.set(
const failureResult = await cache.set(
cacheKey,
{ userId: data.userId, error: error instanceof Error ? error.message : "Translation failed" },
AI_TRANSLATION_RESULT_TTL_MS
);
if (!failureResult.ok) {
logger.error({ jobId: context.jobId }, "Failed to store AI translation failure marker in cache");
}
}
throw error;
}
@@ -39,7 +39,7 @@ export const RichTextTranslationInput = ({
}, [disabled]);
return (
<div className={disabled ? "pointer-events-none rounded-md opacity-60" : "rounded-md"}>
<div className={disabled ? "cursor-not-allowed rounded-md opacity-60" : "rounded-md"}>
<Editor
key={`${path}-${editorKey}`}
disableLists