diff --git a/web/src/app/api/config/route.test.ts b/web/src/app/api/config/route.test.ts new file mode 100644 index 0000000..eb85b4f --- /dev/null +++ b/web/src/app/api/config/route.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { GET, PUT } from "./route"; +import { prisma } from "@/lib/db"; + +beforeAll(() => { + process.env.LYRA_SECRET_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="; +}); + +function putReq(body: unknown) { + return new Request("http://localhost/api/config", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("config API", () => { + it("stores plain fields verbatim and secret fields encrypted", async () => { + await PUT(putReq({ qobuzEmail: "me@example.com", qobuzPassword: "hunter2", slskdUrl: "http://slskd:5030", slskdApiKey: "abc" })); + + const email = await prisma.config.findUnique({ where: { key: "qobuz.email" } }); + const pw = await prisma.config.findUnique({ where: { key: "qobuz.password" } }); + expect(email!.value).toBe("me@example.com"); + expect(email!.secret).toBe(false); + expect(pw!.secret).toBe(true); + expect(pw!.value).not.toContain("hunter2"); // encrypted envelope + }); + + it("GET masks secrets and reports set-ness", async () => { + await PUT(putReq({ qobuzEmail: "me@example.com", qobuzPassword: "hunter2" })); + const res = await GET(); + const body = await res.json(); + expect(body.qobuzEmail).toBe("me@example.com"); + expect(body.qobuzPasswordSet).toBe(true); + expect(body.slskdApiKeySet).toBe(false); + expect(JSON.stringify(body)).not.toContain("hunter2"); + }); + + it("empty string leaves a field unchanged", async () => { + await PUT(putReq({ qobuzPassword: "first" })); + await PUT(putReq({ qobuzPassword: "" })); + const pw = await prisma.config.findUnique({ where: { key: "qobuz.password" } }); + expect(pw).not.toBeNull(); // still present, not wiped + }); +}); diff --git a/web/src/app/api/config/route.ts b/web/src/app/api/config/route.ts new file mode 100644 index 0000000..23078fd --- /dev/null +++ b/web/src/app/api/config/route.ts @@ -0,0 +1,43 @@ +import { prisma } from "@/lib/db"; +import { encryptSecret } from "@/lib/crypto"; + +// settings field -> (config key, is secret) +const FIELDS: Record = { + qobuzEmail: { key: "qobuz.email", secret: false }, + qobuzPassword: { key: "qobuz.password", secret: true }, + slskdUrl: { key: "slskd.url", secret: false }, + slskdApiKey: { key: "slskd.api_key", secret: true }, +}; + +export async function PUT(request: Request) { + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return Response.json({ error: "invalid JSON" }, { status: 400 }); + } + + for (const [field, { key, secret }] of Object.entries(FIELDS)) { + const raw = body[field]; + if (typeof raw !== "string" || raw === "") continue; // absent or "unchanged" + const value = secret ? encryptSecret(raw) : raw; + await prisma.config.upsert({ + where: { key }, + create: { key, value, secret }, + update: { value, secret }, + }); + } + + return Response.json({ ok: true }); +} + +export async function GET() { + const rows = await prisma.config.findMany(); + const byKey = new Map(rows.map((r) => [r.key, r])); + return Response.json({ + qobuzEmail: byKey.get("qobuz.email")?.value ?? "", + slskdUrl: byKey.get("slskd.url")?.value ?? "", + qobuzPasswordSet: byKey.has("qobuz.password"), + slskdApiKeySet: byKey.has("slskd.api_key"), + }); +} diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index 2a1b805..b83f3da 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -5,4 +5,5 @@ beforeEach(async () => { // Job has a cascade FK to Request; delete Job first. await prisma.job.deleteMany(); await prisma.request.deleteMany(); + await prisma.config.deleteMany(); });