mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: enforce settings authority boundaries (#618)
Close generic settings authorization bypasses and enforce per-setting authority, validation, redaction, transactional config import, and route-local write rate limiting.
This commit is contained in:
@@ -77,7 +77,7 @@ describe("API key permission scoping", () => {
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${apiKey}` },
|
||||
payload: { testSetting: "hacked" },
|
||||
payload: { defaultTheme: "dark" },
|
||||
});
|
||||
expect(writeRes.statusCode).toBe(403);
|
||||
});
|
||||
@@ -96,7 +96,7 @@ describe("API key permission scoping", () => {
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${body.key}` },
|
||||
payload: { testSetting: "value" },
|
||||
payload: { defaultTheme: "dark" },
|
||||
});
|
||||
expect(writeRes.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
@@ -1315,7 +1315,7 @@ describe("Auth middleware", () => {
|
||||
const res = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
payload: { theme: "dark" },
|
||||
payload: { defaultTheme: "dark" },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
@@ -1364,7 +1364,7 @@ describe("Settings", () => {
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { theme: "dark", locale: "en" },
|
||||
payload: { defaultTheme: "dark", defaultLocale: "en" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
@@ -1378,19 +1378,19 @@ describe("Settings", () => {
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { testKey: "testValue" },
|
||||
payload: { defaultToolView: "fullscreen" },
|
||||
});
|
||||
|
||||
// Retrieve
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings/testKey",
|
||||
url: "/api/v1/settings/defaultToolView",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.key).toBe("testKey");
|
||||
expect(body.value).toBe("testValue");
|
||||
expect(body.key).toBe("defaultToolView");
|
||||
expect(body.value).toBe("fullscreen");
|
||||
});
|
||||
|
||||
it("treats a redacted secret mask as a no-op instead of overwriting the secret", async () => {
|
||||
@@ -1414,7 +1414,7 @@ describe("Settings", () => {
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { oidc_client_secret: "********", theme: "dark" },
|
||||
payload: { oidc_client_secret: "********", defaultTheme: "dark" },
|
||||
});
|
||||
expect(putRes.statusCode).toBe(200);
|
||||
expect(JSON.parse(putRes.body).updatedCount).toBe(1);
|
||||
@@ -1439,7 +1439,7 @@ describe("Settings", () => {
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${userToken}` },
|
||||
payload: { theme: "hacked" },
|
||||
payload: { defaultTheme: "dark" },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
@@ -1463,7 +1463,7 @@ describe("Settings", () => {
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { upsertKey: "original" },
|
||||
payload: { defaultTheme: "system" },
|
||||
});
|
||||
|
||||
// Update
|
||||
@@ -1471,16 +1471,16 @@ describe("Settings", () => {
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { upsertKey: "updated" },
|
||||
payload: { defaultTheme: "light" },
|
||||
});
|
||||
|
||||
// Verify
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings/upsertKey",
|
||||
url: "/api/v1/settings/defaultTheme",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(JSON.parse(res.body).value).toBe("updated");
|
||||
expect(JSON.parse(res.body).value).toBe("light");
|
||||
});
|
||||
|
||||
it("rejects HTML tags in setting values", async () => {
|
||||
@@ -1488,7 +1488,7 @@ describe("Settings", () => {
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { test_setting: "<script>alert('xss')</script>" },
|
||||
payload: { defaultTheme: "<script>alert('xss')</script>" },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
const body = JSON.parse(res.body);
|
||||
@@ -1508,13 +1508,18 @@ describe("Settings", () => {
|
||||
});
|
||||
|
||||
it("does not partially write entries when a later entry contains HTML tags", async () => {
|
||||
const cleanKey = `atomicity_test_clean_${Date.now()}`;
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { defaultTheme: "system" },
|
||||
});
|
||||
const res = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: {
|
||||
[cleanKey]: "safe_value",
|
||||
defaultTheme: "dark",
|
||||
"<script>xss</script>": "evil",
|
||||
},
|
||||
});
|
||||
@@ -1525,10 +1530,11 @@ describe("Settings", () => {
|
||||
// The clean entry must NOT have been written
|
||||
const getRes = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/v1/settings/${cleanKey}`,
|
||||
url: "/api/v1/settings/defaultTheme",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(getRes.statusCode).toBe(404);
|
||||
expect(getRes.statusCode).toBe(200);
|
||||
expect(JSON.parse(getRes.body).value).toBe("system");
|
||||
});
|
||||
|
||||
it("allows normal setting values without HTML", async () => {
|
||||
@@ -1536,7 +1542,7 @@ describe("Settings", () => {
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { test_setting: "My App (v2.0) - Production" },
|
||||
payload: { defaultTheme: "system" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
|
||||
@@ -144,6 +144,7 @@ describe("config export with enterprise license", () => {
|
||||
"scim_token_hash",
|
||||
"oidc_client_secret",
|
||||
"saml_idp_certificate",
|
||||
"siem_webhook_auth",
|
||||
"siem_last_forwarded_at",
|
||||
"siem_consecutive_failures",
|
||||
"audit_archival_state",
|
||||
@@ -166,6 +167,122 @@ describe("config export with enterprise license", () => {
|
||||
expect(body.settings[key]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("exports the canonical password-digit key without its legacy alias", async () => {
|
||||
const keys = ["passwordRequireDigit", "passwordRequireNumber"];
|
||||
const originalRows = await db.select().from(schema.settings);
|
||||
const originals = new Map(
|
||||
originalRows.filter((row) => keys.includes(row.key)).map((row) => [row.key, row.value]),
|
||||
);
|
||||
|
||||
try {
|
||||
await db
|
||||
.insert(schema.settings)
|
||||
.values({ key: "passwordRequireDigit", value: "false" })
|
||||
.onConflictDoUpdate({ target: schema.settings.key, set: { value: "false" } });
|
||||
await db
|
||||
.insert(schema.settings)
|
||||
.values({ key: "passwordRequireNumber", value: "true" })
|
||||
.onConflictDoUpdate({ target: schema.settings.key, set: { value: "true" } });
|
||||
|
||||
const res = await licensedApp.app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/enterprise/config/export",
|
||||
headers: { authorization: `Bearer ${licensedToken}` },
|
||||
});
|
||||
const body = JSON.parse(res.body);
|
||||
|
||||
expect(res.statusCode, res.body).toBe(200);
|
||||
expect(body.settings.passwordRequireDigit).toBe("false");
|
||||
expect(body.settings.passwordRequireNumber).toBeUndefined();
|
||||
} finally {
|
||||
for (const key of keys) {
|
||||
const original = originals.get(key);
|
||||
if (original === undefined) {
|
||||
await db.delete(schema.settings).where(eq(schema.settings.key, key));
|
||||
} else {
|
||||
await db
|
||||
.update(schema.settings)
|
||||
.set({ value: original })
|
||||
.where(eq(schema.settings.key, key));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("denies config export to a custom role even with every admin permission", async () => {
|
||||
const suffix = Date.now().toString(36);
|
||||
const roleName = `config-export-${suffix}`;
|
||||
const username = `config-export-user-${suffix}`;
|
||||
let roleId: string | undefined;
|
||||
let userId: string | undefined;
|
||||
|
||||
try {
|
||||
const roleRes = await licensedApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/roles",
|
||||
headers: { authorization: `Bearer ${licensedToken}` },
|
||||
payload: { name: roleName, permissions: ADMIN_PERMISSIONS },
|
||||
});
|
||||
expect(roleRes.statusCode, roleRes.body).toBe(201);
|
||||
roleId = JSON.parse(roleRes.body).id as string;
|
||||
|
||||
const registerRes = await licensedApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/register",
|
||||
headers: { authorization: `Bearer ${licensedToken}` },
|
||||
payload: { username, password: "TestPass1", role: roleName },
|
||||
});
|
||||
expect(registerRes.statusCode, registerRes.body).toBe(201);
|
||||
userId = JSON.parse(registerRes.body).id as string;
|
||||
await db
|
||||
.update(schema.users)
|
||||
.set({ mustChangePassword: false })
|
||||
.where(eq(schema.users.id, userId));
|
||||
|
||||
const loginRes = await licensedApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
payload: { username, password: "TestPass1" },
|
||||
});
|
||||
const actorToken = JSON.parse(loginRes.body).token as string;
|
||||
const res = await licensedApp.app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/enterprise/config/export",
|
||||
headers: { authorization: `Bearer ${actorToken}` },
|
||||
});
|
||||
|
||||
expect.soft(res.statusCode, res.body).toBe(403);
|
||||
expect(JSON.parse(res.body).code).toBe("ESCALATION_DENIED");
|
||||
} finally {
|
||||
if (userId) await db.delete(schema.users).where(eq(schema.users.id, userId));
|
||||
if (roleId) await db.delete(schema.roles).where(eq(schema.roles.id, roleId));
|
||||
}
|
||||
});
|
||||
|
||||
it("denies config export through a permission-scoped built-in admin API key", async () => {
|
||||
const suffix = Date.now().toString(36);
|
||||
const keyRes = await licensedApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/api-keys",
|
||||
headers: { authorization: `Bearer ${licensedToken}` },
|
||||
payload: {
|
||||
name: `config-export-scoped-${suffix}`,
|
||||
permissions: ["system:health"],
|
||||
},
|
||||
});
|
||||
expect(keyRes.statusCode, keyRes.body).toBe(201);
|
||||
const scopedKey = JSON.parse(keyRes.body).key as string;
|
||||
|
||||
const res = await licensedApp.app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/enterprise/config/export",
|
||||
headers: { authorization: `Bearer ${scopedKey}` },
|
||||
});
|
||||
|
||||
expect.soft(res.statusCode, res.body).toBe(403);
|
||||
expect(JSON.parse(res.body).code).toBe("ESCALATION_DENIED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("config import with enterprise license", () => {
|
||||
@@ -399,9 +516,10 @@ describe("config import with enterprise license", () => {
|
||||
|
||||
it("dry-run reports setting, role, and team changes without mutating them", async () => {
|
||||
const suffix = Date.now().toString(36);
|
||||
const settingKey = `configDryRunSetting${suffix}`;
|
||||
const settingKey = "defaultToolView";
|
||||
const roleName = `config-dry-run-role-${suffix}`;
|
||||
const teamName = `config-dry-run-team-${suffix}`;
|
||||
await db.delete(schema.settings).where(eq(schema.settings.key, settingKey));
|
||||
|
||||
const res = await licensedApp.app.inject({
|
||||
method: "POST",
|
||||
@@ -411,7 +529,7 @@ describe("config import with enterprise license", () => {
|
||||
dryRun: true,
|
||||
config: {
|
||||
configSchemaVersion: 1,
|
||||
settings: { [settingKey]: "hello" },
|
||||
settings: { [settingKey]: "fullscreen" },
|
||||
roles: [{ name: roleName, permissions: ["settings:read"] }],
|
||||
teams: [{ name: teamName }],
|
||||
},
|
||||
@@ -440,6 +558,35 @@ describe("config import with enterprise license", () => {
|
||||
expect(team).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ field: "retentionHours", value: 2_147_483_648 },
|
||||
{ field: "storageQuota", value: Number.MAX_SAFE_INTEGER + 1 },
|
||||
])("rejects out-of-range team $field during dry-run and apply", async ({ field, value }) => {
|
||||
const teamName = `config-out-of-range-${field}-${Date.now().toString(36)}`;
|
||||
const config = {
|
||||
configSchemaVersion: 1,
|
||||
teams: [{ name: teamName, [field]: value }],
|
||||
};
|
||||
|
||||
try {
|
||||
for (const dryRun of [true, false]) {
|
||||
const res = await licensedApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/enterprise/config/import",
|
||||
headers: { authorization: `Bearer ${licensedToken}` },
|
||||
payload: { dryRun, config },
|
||||
});
|
||||
|
||||
expect(res.statusCode, res.body).toBe(400);
|
||||
}
|
||||
|
||||
const [team] = await db.select().from(schema.teams).where(eq(schema.teams.name, teamName));
|
||||
expect(team).toBeUndefined();
|
||||
} finally {
|
||||
await db.delete(schema.teams).where(eq(schema.teams.name, teamName));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects future schema versions", async () => {
|
||||
const res = await licensedApp.app.inject({
|
||||
method: "POST",
|
||||
@@ -473,9 +620,258 @@ describe("config import with enterprise license", () => {
|
||||
expect(body.changes.teams).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects an unlicensed MFA policy atomically during config import", async () => {
|
||||
await db
|
||||
.insert(schema.settings)
|
||||
.values({ key: "defaultTheme", value: "system" })
|
||||
.onConflictDoUpdate({ target: schema.settings.key, set: { value: "system" } });
|
||||
await db
|
||||
.insert(schema.settings)
|
||||
.values({ key: "mfaPolicy", value: "optional" })
|
||||
.onConflictDoUpdate({ target: schema.settings.key, set: { value: "optional" } });
|
||||
|
||||
const res = await licensedApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/enterprise/config/import",
|
||||
headers: { authorization: `Bearer ${licensedToken}` },
|
||||
payload: {
|
||||
dryRun: false,
|
||||
config: {
|
||||
configSchemaVersion: 1,
|
||||
settings: { defaultTheme: "dark", mfaPolicy: "required" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const [theme] = await db
|
||||
.select()
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, "defaultTheme"));
|
||||
const [mfaPolicy] = await db
|
||||
.select()
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, "mfaPolicy"));
|
||||
|
||||
expect.soft(res.statusCode, res.body).toBe(403);
|
||||
expect.soft(JSON.parse(res.body).code).toBe("FEATURE_NOT_LICENSED");
|
||||
expect.soft(theme?.value).toBe("system");
|
||||
expect(mfaPolicy?.value).toBe("optional");
|
||||
});
|
||||
|
||||
it("rejects SSO enforcement without a configured provider atomically", async () => {
|
||||
await db
|
||||
.insert(schema.settings)
|
||||
.values({ key: "defaultTheme", value: "system" })
|
||||
.onConflictDoUpdate({ target: schema.settings.key, set: { value: "system" } });
|
||||
await db
|
||||
.insert(schema.settings)
|
||||
.values({ key: "ssoEnforcement", value: "false" })
|
||||
.onConflictDoUpdate({ target: schema.settings.key, set: { value: "false" } });
|
||||
|
||||
const res = await licensedApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/enterprise/config/import",
|
||||
headers: { authorization: `Bearer ${licensedToken}` },
|
||||
payload: {
|
||||
dryRun: false,
|
||||
config: {
|
||||
configSchemaVersion: 1,
|
||||
settings: { defaultTheme: "dark", ssoEnforcement: "true" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const [theme] = await db
|
||||
.select()
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, "defaultTheme"));
|
||||
const [ssoEnforcement] = await db
|
||||
.select()
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, "ssoEnforcement"));
|
||||
|
||||
expect.soft(res.statusCode, res.body).toBe(400);
|
||||
expect.soft(JSON.parse(res.body).code).toBe("DEPENDENCY_VALIDATION_FAILED");
|
||||
expect.soft(theme?.value).toBe("system");
|
||||
expect(ssoEnforcement?.value).toBe("false");
|
||||
});
|
||||
|
||||
it("rejects duplicate imported roles without partially applying settings", async () => {
|
||||
const roleName = `config-duplicate-role-${Date.now().toString(36)}`;
|
||||
await db
|
||||
.insert(schema.settings)
|
||||
.values({ key: "defaultTheme", value: "system" })
|
||||
.onConflictDoUpdate({ target: schema.settings.key, set: { value: "system" } });
|
||||
|
||||
try {
|
||||
const res = await licensedApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/enterprise/config/import",
|
||||
headers: { authorization: `Bearer ${licensedToken}` },
|
||||
payload: {
|
||||
dryRun: false,
|
||||
config: {
|
||||
configSchemaVersion: 1,
|
||||
settings: { defaultTheme: "dark" },
|
||||
roles: [
|
||||
{ name: roleName, permissions: ["settings:read"] },
|
||||
{ name: roleName, permissions: ["settings:read"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const [theme] = await db
|
||||
.select()
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, "defaultTheme"));
|
||||
const [role] = await db.select().from(schema.roles).where(eq(schema.roles.name, roleName));
|
||||
|
||||
expect.soft(res.statusCode, res.body).toBe(400);
|
||||
expect.soft(JSON.parse(res.body).code).toBe("VALIDATION_ERROR");
|
||||
expect.soft(theme?.value).toBe("system");
|
||||
expect(role).toBeUndefined();
|
||||
} finally {
|
||||
await db.delete(schema.roles).where(eq(schema.roles.name, roleName));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects built-in role names without partially applying settings", async () => {
|
||||
await db
|
||||
.insert(schema.settings)
|
||||
.values({ key: "defaultTheme", value: "system" })
|
||||
.onConflictDoUpdate({ target: schema.settings.key, set: { value: "system" } });
|
||||
|
||||
const res = await licensedApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/enterprise/config/import",
|
||||
headers: { authorization: `Bearer ${licensedToken}` },
|
||||
payload: {
|
||||
dryRun: false,
|
||||
config: {
|
||||
configSchemaVersion: 1,
|
||||
settings: { defaultTheme: "dark" },
|
||||
roles: [{ name: "admin", permissions: ["settings:read"] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const [theme] = await db
|
||||
.select()
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, "defaultTheme"));
|
||||
const [adminRole] = await db.select().from(schema.roles).where(eq(schema.roles.name, "admin"));
|
||||
|
||||
expect.soft(res.statusCode, res.body).toBe(400);
|
||||
expect.soft(JSON.parse(res.body).code).toBe("VALIDATION_ERROR");
|
||||
expect.soft(theme?.value).toBe("system");
|
||||
expect(adminRole?.isBuiltin).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects duplicate imported teams without partially applying settings", async () => {
|
||||
const teamName = `Config Duplicate Team ${Date.now().toString(36)}`;
|
||||
await db
|
||||
.insert(schema.settings)
|
||||
.values({ key: "defaultTheme", value: "system" })
|
||||
.onConflictDoUpdate({ target: schema.settings.key, set: { value: "system" } });
|
||||
|
||||
try {
|
||||
const res = await licensedApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/enterprise/config/import",
|
||||
headers: { authorization: `Bearer ${licensedToken}` },
|
||||
payload: {
|
||||
dryRun: false,
|
||||
config: {
|
||||
configSchemaVersion: 1,
|
||||
settings: { defaultTheme: "dark" },
|
||||
teams: [{ name: teamName }, { name: teamName }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const [theme] = await db
|
||||
.select()
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, "defaultTheme"));
|
||||
const [team] = await db.select().from(schema.teams).where(eq(schema.teams.name, teamName));
|
||||
|
||||
expect.soft(res.statusCode, res.body).toBe(400);
|
||||
expect.soft(JSON.parse(res.body).code).toBe("VALIDATION_ERROR");
|
||||
expect.soft(theme?.value).toBe("system");
|
||||
expect(team).toBeUndefined();
|
||||
} finally {
|
||||
await db.delete(schema.teams).where(eq(schema.teams.name, teamName));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects dedicated settings atomically during config import", async () => {
|
||||
await db
|
||||
.insert(schema.settings)
|
||||
.values({ key: "defaultTheme", value: "system" })
|
||||
.onConflictDoUpdate({ target: schema.settings.key, set: { value: "system" } });
|
||||
await db
|
||||
.insert(schema.settings)
|
||||
.values({ key: "ipAllowlist", value: "[]" })
|
||||
.onConflictDoUpdate({ target: schema.settings.key, set: { value: "[]" } });
|
||||
|
||||
const res = await licensedApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/enterprise/config/import",
|
||||
headers: { authorization: `Bearer ${licensedToken}` },
|
||||
payload: {
|
||||
dryRun: false,
|
||||
config: {
|
||||
configSchemaVersion: 1,
|
||||
settings: { defaultTheme: "dark", ipAllowlist: '["0.0.0.0/0"]' },
|
||||
},
|
||||
},
|
||||
});
|
||||
const [theme] = await db
|
||||
.select()
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, "defaultTheme"));
|
||||
const [allowlist] = await db
|
||||
.select()
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, "ipAllowlist"));
|
||||
|
||||
expect.soft(res.statusCode, res.body).toBe(400);
|
||||
expect.soft(JSON.parse(res.body).code).toBe("READONLY_SETTING");
|
||||
expect.soft(theme?.value).toBe("system");
|
||||
expect(allowlist?.value).toBe("[]");
|
||||
});
|
||||
|
||||
it("rejects unknown and malformed setting keys during config import", async () => {
|
||||
const unknownKey = `config_unknown_${Date.now().toString(36)}`;
|
||||
const unknownRes = await licensedApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/enterprise/config/import",
|
||||
headers: { authorization: `Bearer ${licensedToken}` },
|
||||
payload: {
|
||||
dryRun: false,
|
||||
config: { configSchemaVersion: 1, settings: { [unknownKey]: "value" } },
|
||||
},
|
||||
});
|
||||
const malformedRes = await licensedApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/enterprise/config/import",
|
||||
headers: { authorization: `Bearer ${licensedToken}` },
|
||||
payload: {
|
||||
dryRun: false,
|
||||
config: { configSchemaVersion: 1, settings: { loginAttemptLimit: "0" } },
|
||||
},
|
||||
});
|
||||
const [unknownSetting] = await db
|
||||
.select()
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, unknownKey));
|
||||
|
||||
expect.soft(unknownRes.statusCode, unknownRes.body).toBe(400);
|
||||
expect.soft(JSON.parse(unknownRes.body).code).toBe("UNKNOWN_SETTING");
|
||||
expect.soft(malformedRes.statusCode, malformedRes.body).toBe(400);
|
||||
expect.soft(JSON.parse(malformedRes.body).code).toBe("VALIDATION_ERROR");
|
||||
expect(unknownSetting).toBeUndefined();
|
||||
});
|
||||
|
||||
it("import with valid settings applies them", async () => {
|
||||
const settingKey = "configImportTestKey";
|
||||
const settingValue = "configImportTestValue";
|
||||
const settingKey = "defaultLocale";
|
||||
const settingValue = "fr";
|
||||
|
||||
const res = await licensedApp.app.inject({
|
||||
method: "POST",
|
||||
@@ -521,7 +917,7 @@ describe("config round-trip", () => {
|
||||
vi.restoreAllMocks();
|
||||
}, 10_000);
|
||||
|
||||
it("export then dry-run import reports 0 changes", async () => {
|
||||
it("export then dry-run import classifies existing records as updates", async () => {
|
||||
const exportRes = await licensedApp.app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/enterprise/config/export",
|
||||
|
||||
@@ -145,7 +145,7 @@ describe("permission enforcement on routes", () => {
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${userToken}` },
|
||||
payload: { appTitle: "Hacked" },
|
||||
payload: { defaultTheme: "dark" },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
@@ -220,7 +220,7 @@ describe("permission enforcement on routes", () => {
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { appTitle: "Test Title" },
|
||||
payload: { defaultTheme: "dark" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
@@ -239,7 +239,7 @@ describe("permission enforcement on routes", () => {
|
||||
const res = await testApp.app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
payload: { appTitle: "Hacked" },
|
||||
payload: { defaultTheme: "dark" },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
@@ -152,7 +152,7 @@ const routes: RouteTest[] = [
|
||||
{
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
payload: { _test: "v" },
|
||||
payload: { defaultTheme: "dark" },
|
||||
admin: 200,
|
||||
editor: 403,
|
||||
user: 403,
|
||||
|
||||
@@ -69,7 +69,7 @@ const routes: RouteTest[] = [
|
||||
{
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
payload: { _test: "v" },
|
||||
payload: { defaultTheme: "dark" },
|
||||
admin: 200,
|
||||
editor: 403,
|
||||
user: 403,
|
||||
|
||||
@@ -506,6 +506,47 @@ describe("SCIM global token administration", () => {
|
||||
expect(storedToken?.value).toBe(originalHash);
|
||||
});
|
||||
|
||||
it("prevents a settings manager from replacing the global SCIM credential", async () => {
|
||||
const attackerToken = `so_scim_v2_${"b".repeat(64)}`;
|
||||
const originalHash = await hashPassword(SCIM_TOKEN);
|
||||
const attackerHash = await hashPassword(attackerToken);
|
||||
await db
|
||||
.insert(schema.settings)
|
||||
.values({ key: "scim_token_hash", value: originalHash })
|
||||
.onConflictDoUpdate({
|
||||
target: schema.settings.key,
|
||||
set: { value: originalHash },
|
||||
});
|
||||
|
||||
try {
|
||||
const settingsRes = await licensedApp.app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${managerToken}` },
|
||||
payload: { scim_token_hash: attackerHash },
|
||||
});
|
||||
const [storedToken] = await db
|
||||
.select()
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, "scim_token_hash"));
|
||||
const scimRes = await licensedApp.app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/scim/v2/Users",
|
||||
headers: { authorization: `Bearer ${attackerToken}` },
|
||||
});
|
||||
|
||||
expect.soft(settingsRes.statusCode).toBe(400);
|
||||
expect.soft(JSON.parse(settingsRes.body).code).toBe("READONLY_SETTING");
|
||||
expect.soft(storedToken?.value).toBe(originalHash);
|
||||
expect(scimRes.statusCode).toBe(401);
|
||||
} finally {
|
||||
await db
|
||||
.update(schema.settings)
|
||||
.set({ value: originalHash })
|
||||
.where(eq(schema.settings.key, "scim_token_hash"));
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ method: "POST" as const, operation: "issuance" },
|
||||
{ method: "DELETE" as const, operation: "revocation" },
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { db, schema } from "../../../apps/api/src/db/index.js";
|
||||
import { buildTestApp, loginAsAdmin, type TestApp } from "../test-server.js";
|
||||
|
||||
const DEDICATED_OR_SERVER_SETTING_KEYS = [
|
||||
"cookie_secret",
|
||||
"instance_id",
|
||||
"scim_token_hash",
|
||||
"siem_config",
|
||||
"webhook_destinations",
|
||||
"ipAllowlist",
|
||||
"siem_last_forwarded_at",
|
||||
"siem_consecutive_failures",
|
||||
"audit_archival_state",
|
||||
"backup_last_completed",
|
||||
"sqlite_import",
|
||||
"onboarding.firstProcessedAt",
|
||||
] as const;
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
let settingsManagerToken: string;
|
||||
let settingsManagerRoleId: string;
|
||||
let settingsManagerUserId: string;
|
||||
let settingsOnlyApiKey: string;
|
||||
let securityApiKey: string;
|
||||
let complianceApiKey: string;
|
||||
const originalSettings = new Map<string, string | undefined>();
|
||||
|
||||
async function upsertSetting(key: string, value: string): Promise<void> {
|
||||
await db
|
||||
.insert(schema.settings)
|
||||
.values({ key, value })
|
||||
.onConflictDoUpdate({ target: schema.settings.key, set: { value } });
|
||||
}
|
||||
|
||||
async function readSetting(key: string): Promise<string | undefined> {
|
||||
const [row] = await db
|
||||
.select({ value: schema.settings.value })
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, key));
|
||||
return row?.value;
|
||||
}
|
||||
|
||||
async function createApiKey(name: string, permissions: string[]): Promise<string> {
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/api-keys",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { name, permissions },
|
||||
});
|
||||
if (res.statusCode !== 201) throw new Error(`Failed to create ${name}: ${res.body}`);
|
||||
return JSON.parse(res.body).key as string;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
|
||||
for (const key of [
|
||||
...DEDICATED_OR_SERVER_SETTING_KEYS,
|
||||
"defaultTheme",
|
||||
"loginAttemptLimit",
|
||||
"auditRetentionDays",
|
||||
"oidc_client_secret",
|
||||
"passwordRequireDigit",
|
||||
"passwordRequireNumber",
|
||||
"ssoEnforcement",
|
||||
]) {
|
||||
originalSettings.set(key, await readSetting(key));
|
||||
}
|
||||
|
||||
const suffix = Date.now().toString(36);
|
||||
const roleName = `settings-manager-${suffix}`;
|
||||
const username = `settings-manager-user-${suffix}`;
|
||||
const roleRes = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/roles",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { name: roleName, permissions: ["settings:read", "settings:write"] },
|
||||
});
|
||||
if (roleRes.statusCode !== 201) throw new Error(`Failed to create role: ${roleRes.body}`);
|
||||
settingsManagerRoleId = JSON.parse(roleRes.body).id as string;
|
||||
|
||||
const registerRes = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/register",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { username, password: "SettingsPass1", role: roleName },
|
||||
});
|
||||
if (registerRes.statusCode !== 201) {
|
||||
throw new Error(`Failed to create settings manager: ${registerRes.body}`);
|
||||
}
|
||||
settingsManagerUserId = JSON.parse(registerRes.body).id as string;
|
||||
await db
|
||||
.update(schema.users)
|
||||
.set({ mustChangePassword: false })
|
||||
.where(eq(schema.users.id, settingsManagerUserId));
|
||||
|
||||
const loginRes = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
payload: { username, password: "SettingsPass1" },
|
||||
});
|
||||
if (loginRes.statusCode !== 200) throw new Error(`Failed to log in manager: ${loginRes.body}`);
|
||||
settingsManagerToken = JSON.parse(loginRes.body).token as string;
|
||||
|
||||
settingsOnlyApiKey = await createApiKey(`settings-only-${suffix}`, [
|
||||
"settings:read",
|
||||
"settings:write",
|
||||
]);
|
||||
securityApiKey = await createApiKey(`settings-security-${suffix}`, [
|
||||
"settings:read",
|
||||
"settings:write",
|
||||
"security:manage",
|
||||
]);
|
||||
complianceApiKey = await createApiKey(`settings-compliance-${suffix}`, [
|
||||
"settings:read",
|
||||
"settings:write",
|
||||
"compliance:manage",
|
||||
]);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
for (const [key, value] of originalSettings) {
|
||||
if (value === undefined) {
|
||||
await db.delete(schema.settings).where(eq(schema.settings.key, key));
|
||||
} else {
|
||||
await upsertSetting(key, value);
|
||||
}
|
||||
}
|
||||
if (settingsManagerUserId) {
|
||||
await db.delete(schema.users).where(eq(schema.users.id, settingsManagerUserId));
|
||||
}
|
||||
if (settingsManagerRoleId) {
|
||||
await db.delete(schema.roles).where(eq(schema.roles.id, settingsManagerRoleId));
|
||||
}
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe("generic settings authority", () => {
|
||||
it("keeps ordinary settings available to a delegated settings manager", async () => {
|
||||
await upsertSetting("defaultTheme", "system");
|
||||
const res = await testApp.app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${settingsManagerToken}` },
|
||||
payload: { defaultTheme: "dark" },
|
||||
});
|
||||
|
||||
expect(res.statusCode, res.body).toBe(200);
|
||||
expect(await readSetting("defaultTheme")).toBe("dark");
|
||||
});
|
||||
|
||||
it("keeps ordinary settings available to a permission-scoped API key", async () => {
|
||||
const res = await testApp.app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${settingsOnlyApiKey}` },
|
||||
payload: { defaultTheme: "light" },
|
||||
});
|
||||
|
||||
expect(res.statusCode, res.body).toBe(200);
|
||||
expect(await readSetting("defaultTheme")).toBe("light");
|
||||
});
|
||||
|
||||
it.each(DEDICATED_OR_SERVER_SETTING_KEYS)(
|
||||
"rejects generic writes to protected setting %s without changing it",
|
||||
async (key) => {
|
||||
const originalValue = (await readSetting(key)) ?? `original-${key}`;
|
||||
if ((await readSetting(key)) === undefined) await upsertSetting(key, originalValue);
|
||||
|
||||
const res = await testApp.app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${settingsManagerToken}` },
|
||||
payload: { [key]: `replacement-${key}` },
|
||||
});
|
||||
|
||||
expect.soft(res.statusCode, res.body).toBe(400);
|
||||
expect.soft(JSON.parse(res.body).code).toBe("READONLY_SETTING");
|
||||
expect(await readSetting(key)).toBe(originalValue);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects dedicated-endpoint settings even for a full administrator", async () => {
|
||||
const originalValue = "full-admin-scim-sentinel";
|
||||
await upsertSetting("scim_token_hash", originalValue);
|
||||
|
||||
const res = await testApp.app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { scim_token_hash: "full-admin-replacement" },
|
||||
});
|
||||
|
||||
expect.soft(res.statusCode, res.body).toBe(400);
|
||||
expect.soft(JSON.parse(res.body).code).toBe("READONLY_SETTING");
|
||||
expect(await readSetting("scim_token_hash")).toBe(originalValue);
|
||||
});
|
||||
|
||||
it("requires security authority for authentication-policy settings", async () => {
|
||||
await upsertSetting("loginAttemptLimit", "5");
|
||||
|
||||
const res = await testApp.app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${settingsOnlyApiKey}` },
|
||||
payload: { loginAttemptLimit: "25" },
|
||||
});
|
||||
|
||||
expect.soft(res.statusCode, res.body).toBe(403);
|
||||
expect.soft(JSON.parse(res.body).code).toBe("FORBIDDEN");
|
||||
expect(await readSetting("loginAttemptLimit")).toBe("5");
|
||||
});
|
||||
|
||||
it("allows a correctly scoped security manager to update a valid policy", async () => {
|
||||
const res = await testApp.app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${securityApiKey}` },
|
||||
payload: { loginAttemptLimit: "25" },
|
||||
});
|
||||
|
||||
expect(res.statusCode, res.body).toBe(200);
|
||||
expect(await readSetting("loginAttemptLimit")).toBe("25");
|
||||
});
|
||||
|
||||
it("rejects malformed security-policy values", async () => {
|
||||
await upsertSetting("loginAttemptLimit", "5");
|
||||
|
||||
const res = await testApp.app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${securityApiKey}` },
|
||||
payload: { loginAttemptLimit: "0" },
|
||||
});
|
||||
|
||||
expect.soft(res.statusCode, res.body).toBe(400);
|
||||
expect.soft(JSON.parse(res.body).code).toBe("VALIDATION_ERROR");
|
||||
expect(await readSetting("loginAttemptLimit")).toBe("5");
|
||||
});
|
||||
|
||||
it("rejects SSO enforcement without a configured provider atomically", async () => {
|
||||
await upsertSetting("defaultTheme", "system");
|
||||
await upsertSetting("ssoEnforcement", "false");
|
||||
|
||||
const res = await testApp.app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${securityApiKey}` },
|
||||
payload: { defaultTheme: "dark", ssoEnforcement: "true" },
|
||||
});
|
||||
|
||||
expect.soft(res.statusCode, res.body).toBe(400);
|
||||
expect.soft(JSON.parse(res.body).code).toBe("DEPENDENCY_VALIDATION_FAILED");
|
||||
expect.soft(await readSetting("defaultTheme")).toBe("system");
|
||||
expect(await readSetting("ssoEnforcement")).toBe("false");
|
||||
});
|
||||
|
||||
it("requires compliance authority for audit-retention settings", async () => {
|
||||
await upsertSetting("auditRetentionDays", "30");
|
||||
|
||||
const denied = await testApp.app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${settingsOnlyApiKey}` },
|
||||
payload: { auditRetentionDays: "7" },
|
||||
});
|
||||
expect.soft(denied.statusCode, denied.body).toBe(403);
|
||||
expect.soft(await readSetting("auditRetentionDays")).toBe("30");
|
||||
|
||||
const allowed = await testApp.app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${complianceApiKey}` },
|
||||
payload: { auditRetentionDays: "7" },
|
||||
});
|
||||
expect.soft(allowed.statusCode, allowed.body).toBe(200);
|
||||
expect(await readSetting("auditRetentionDays")).toBe("7");
|
||||
});
|
||||
|
||||
it("keeps security and compliance scopes independent", async () => {
|
||||
await upsertSetting("loginAttemptLimit", "5");
|
||||
await upsertSetting("auditRetentionDays", "30");
|
||||
|
||||
const securityToCompliance = await testApp.app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${securityApiKey}` },
|
||||
payload: { auditRetentionDays: "7" },
|
||||
});
|
||||
const complianceToSecurity = await testApp.app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${complianceApiKey}` },
|
||||
payload: { loginAttemptLimit: "25" },
|
||||
});
|
||||
|
||||
expect.soft(securityToCompliance.statusCode, securityToCompliance.body).toBe(403);
|
||||
expect.soft(complianceToSecurity.statusCode, complianceToSecurity.body).toBe(403);
|
||||
expect.soft(await readSetting("auditRetentionDays")).toBe("30");
|
||||
expect(await readSetting("loginAttemptLimit")).toBe("5");
|
||||
});
|
||||
|
||||
it("rejects an unauthorized mixed batch without writing its safe entries", async () => {
|
||||
await upsertSetting("defaultTheme", "system");
|
||||
await upsertSetting("loginAttemptLimit", "5");
|
||||
|
||||
const res = await testApp.app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${settingsManagerToken}` },
|
||||
payload: {
|
||||
defaultTheme: "dark",
|
||||
loginAttemptLimit: "50",
|
||||
},
|
||||
});
|
||||
|
||||
expect.soft(res.statusCode, res.body).toBe(403);
|
||||
expect.soft(await readSetting("defaultTheme")).toBe("system");
|
||||
expect(await readSetting("loginAttemptLimit")).toBe("5");
|
||||
});
|
||||
|
||||
it("rejects a mixed read-only batch without writing its ordinary entries", async () => {
|
||||
await upsertSetting("defaultTheme", "system");
|
||||
await upsertSetting("scim_token_hash", "mixed-batch-sentinel");
|
||||
|
||||
const res = await testApp.app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { defaultTheme: "dark", scim_token_hash: "replacement" },
|
||||
});
|
||||
|
||||
expect.soft(res.statusCode, res.body).toBe(400);
|
||||
expect.soft(await readSetting("defaultTheme")).toBe("system");
|
||||
expect(await readSetting("scim_token_hash")).toBe("mixed-batch-sentinel");
|
||||
});
|
||||
|
||||
it("rejects unknown setting keys instead of granting them default authority", async () => {
|
||||
const unknownKey = `unknown_setting_${Date.now().toString(36)}`;
|
||||
const res = await testApp.app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { [unknownKey]: "value" },
|
||||
});
|
||||
|
||||
expect.soft(res.statusCode, res.body).toBe(400);
|
||||
expect.soft(JSON.parse(res.body).code).toBe("UNKNOWN_SETTING");
|
||||
expect(await readSetting(unknownKey)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not let a scoped built-in-admin key cross the full-admin secret boundary", async () => {
|
||||
await upsertSetting("oidc_client_secret", "secret-value");
|
||||
|
||||
const listRes = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${settingsOnlyApiKey}` },
|
||||
});
|
||||
const keyRes = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings/oidc_client_secret",
|
||||
headers: { authorization: `Bearer ${settingsOnlyApiKey}` },
|
||||
});
|
||||
|
||||
expect.soft(listRes.statusCode, listRes.body).toBe(200);
|
||||
expect.soft(JSON.parse(listRes.body).settings).not.toHaveProperty("oidc_client_secret");
|
||||
expect(keyRes.statusCode, keyRes.body).toBe(403);
|
||||
});
|
||||
|
||||
it("requires security authority to read authentication-policy settings", async () => {
|
||||
await upsertSetting("loginAttemptLimit", "5");
|
||||
|
||||
const deniedList = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${settingsManagerToken}` },
|
||||
});
|
||||
const deniedKey = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings/loginAttemptLimit",
|
||||
headers: { authorization: `Bearer ${settingsManagerToken}` },
|
||||
});
|
||||
const allowedKey = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings/loginAttemptLimit",
|
||||
headers: { authorization: `Bearer ${securityApiKey}` },
|
||||
});
|
||||
|
||||
expect.soft(JSON.parse(deniedList.body).settings).not.toHaveProperty("loginAttemptLimit");
|
||||
expect.soft(deniedKey.statusCode, deniedKey.body).toBe(403);
|
||||
expect(allowedKey.statusCode, allowedKey.body).toBe(200);
|
||||
});
|
||||
|
||||
it("normalizes the legacy password-number alias on writes and reads", async () => {
|
||||
await upsertSetting("passwordRequireNumber", "true");
|
||||
await upsertSetting("passwordRequireDigit", "true");
|
||||
|
||||
const writeRes = await testApp.app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${securityApiKey}` },
|
||||
payload: { passwordRequireNumber: "false" },
|
||||
});
|
||||
const listRes = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${securityApiKey}` },
|
||||
});
|
||||
const keyRes = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings/passwordRequireNumber",
|
||||
headers: { authorization: `Bearer ${securityApiKey}` },
|
||||
});
|
||||
|
||||
expect.soft(writeRes.statusCode, writeRes.body).toBe(200);
|
||||
expect.soft(await readSetting("passwordRequireDigit")).toBe("false");
|
||||
expect.soft(JSON.parse(listRes.body).settings).not.toHaveProperty("passwordRequireNumber");
|
||||
expect.soft(JSON.parse(listRes.body).settings.passwordRequireDigit).toBe("false");
|
||||
expect(keyRes.statusCode, keyRes.body).toBe(200);
|
||||
expect(JSON.parse(keyRes.body)).toMatchObject({
|
||||
key: "passwordRequireDigit",
|
||||
value: "false",
|
||||
});
|
||||
});
|
||||
|
||||
it("reserves legacy identity-provider secrets for full administrators", async () => {
|
||||
const denied = await testApp.app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${settingsManagerToken}` },
|
||||
payload: { oidc_client_secret: "manager-secret" },
|
||||
});
|
||||
expect.soft(denied.statusCode, denied.body).toBe(403);
|
||||
|
||||
const allowed = await testApp.app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { oidc_client_secret: "admin-secret" },
|
||||
});
|
||||
expect.soft(allowed.statusCode, allowed.body).toBe(200);
|
||||
expect(await readSetting("oidc_client_secret")).toBe("admin-secret");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user