mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-13 03:16:58 -05:00
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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "翻訳に失敗しました",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Перевод не удался",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "翻译失败",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user