mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Fixes #529 (opened investigating #515). Setting MFA policy to "required"/"admins only" saved regardless of whether the mfa enterprise feature was licensed, and there was no enrollment UI at all, so any instance that flipped the toggle locked every unenrolled user out with no way back in. The login page and Settings save also both collapsed the resulting error into a generic message, hiding the real reason. - Reject saving mfaPolicy to admins_only/required server-side unless mfa is licensed - Surface the specific server error on login and on a failed settings save instead of a generic fallback - Add a self-service two-factor authentication enrollment flow (QR code, manual entry, recovery codes, verify, disable) so a licensed admin can actually satisfy the policy before it's enforced - Fix a pending-enrollment dead end, silent error swallowing in verify/disable, and a silent clipboard-copy failure on the recovery codes screen - Add the integration test that actually proves the fix: a real login attempt returns 403 MFA_ENROLLMENT_REQUIRED
91 lines
2.7 KiB
TypeScript
91 lines
2.7 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.resetModules();
|
|
const { mockNoEnterprise } = await import("../../helpers/enterprise-mock.js");
|
|
mockNoEnterprise();
|
|
|
|
const { buildTestApp, loginAsAdmin } = await import("../test-server.js");
|
|
|
|
import type { TestApp } from "../test-server.js";
|
|
|
|
let testApp: TestApp;
|
|
let adminToken: string;
|
|
|
|
beforeAll(async () => {
|
|
testApp = await buildTestApp();
|
|
adminToken = await loginAsAdmin(testApp.app);
|
|
}, 30_000);
|
|
|
|
afterAll(async () => {
|
|
await testApp.cleanup();
|
|
}, 10_000);
|
|
|
|
describe("PUT /api/v1/settings mfaPolicy (no mfa license)", () => {
|
|
it("rejects admins_only when mfa is not licensed", async () => {
|
|
const res = await testApp.app.inject({
|
|
method: "PUT",
|
|
url: "/api/v1/settings",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { mfaPolicy: "admins_only" },
|
|
});
|
|
expect(res.statusCode).toBe(403);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.code).toBe("FEATURE_NOT_LICENSED");
|
|
});
|
|
|
|
it("rejects required when mfa is not licensed", async () => {
|
|
const res = await testApp.app.inject({
|
|
method: "PUT",
|
|
url: "/api/v1/settings",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { mfaPolicy: "required" },
|
|
});
|
|
expect(res.statusCode).toBe(403);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.code).toBe("FEATURE_NOT_LICENSED");
|
|
});
|
|
|
|
it("does not persist the rejected value", async () => {
|
|
await testApp.app.inject({
|
|
method: "PUT",
|
|
url: "/api/v1/settings",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { mfaPolicy: "required" },
|
|
});
|
|
|
|
const res = await testApp.app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/settings/mfaPolicy",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it("still allows setting mfaPolicy back to optional", async () => {
|
|
const res = await testApp.app.inject({
|
|
method: "PUT",
|
|
url: "/api/v1/settings",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { mfaPolicy: "optional" },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("does not block unrelated settings in the same request", async () => {
|
|
const res = await testApp.app.inject({
|
|
method: "PUT",
|
|
url: "/api/v1/settings",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { mfaPolicy: "required", sessionIdleTimeoutMinutes: "30" },
|
|
});
|
|
expect(res.statusCode).toBe(403);
|
|
|
|
const check = await testApp.app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/settings/sessionIdleTimeoutMinutes",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(check.statusCode).toBe(404);
|
|
});
|
|
});
|