feat: add config API with encrypted secret storage
This commit is contained in:
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { encryptSecret } from "@/lib/crypto";
|
||||||
|
|
||||||
|
// settings field -> (config key, is secret)
|
||||||
|
const FIELDS: Record<string, { key: string; secret: boolean }> = {
|
||||||
|
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<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} 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"),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,4 +5,5 @@ beforeEach(async () => {
|
|||||||
// Job has a cascade FK to Request; delete Job first.
|
// Job has a cascade FK to Request; delete Job first.
|
||||||
await prisma.job.deleteMany();
|
await prisma.job.deleteMany();
|
||||||
await prisma.request.deleteMany();
|
await prisma.request.deleteMany();
|
||||||
|
await prisma.config.deleteMany();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user