fix(auth): fail closed configuration compatibility

This commit is contained in:
2026-08-16 17:35:40 +02:00
parent a66ef58766
commit e54ce15426
7 changed files with 120 additions and 13 deletions
+44 -1
View File
@@ -1,5 +1,6 @@
import { afterEach, expect, test } from "vitest";
import { afterEach, expect, test, vi } from "vitest";
import { mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { stringify } from "yaml";
@@ -9,6 +10,22 @@ import {
rolesToPermissions,
} from "../src/auth/config.js";
const readHook = vi.hoisted(() => ({ callback: undefined as undefined | (() => void) }));
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
readSync: (...args: any[]) => {
const result = (actual.readSync as any)(...args);
const callback = readHook.callback;
readHook.callback = undefined;
callback?.();
return result;
},
};
});
const directories: string[] = [];
afterEach(() => {
@@ -157,6 +174,22 @@ test("canonical group map order produces one stable revision", () => {
expect(loadAuthenticationConfig(first).revision).toBe(loadAuthenticationConfig(reordered).revision);
});
test("canonical revisions use code-unit ordering for non-ASCII group names", () => {
const loaded = loadAuthenticationConfig(writeFixture(oidcConfig({
authorization: { groupRoles: { "Ångström users": ["user"], "Zebra admins": ["admin"] } },
})));
const canonicalize = (value: unknown): unknown => Array.isArray(value)
? value.map(canonicalize)
: value && typeof value === "object"
? Object.fromEntries(Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
.map(([key, nested]) => [key, canonicalize(nested)]))
: value;
const expected = createHash("sha256").update(JSON.stringify(canonicalize(loaded.value))).digest("hex");
expect(loaded.revision).toBe(expected);
});
test("provider reloads after an atomic configuration replacement", () => {
const file = writeFixture(localConfig());
const provider = createAuthenticationConfigProvider(file);
@@ -170,6 +203,16 @@ test("provider reloads after an atomic configuration replacement", () => {
expect(reloaded.value.publicUrl).toBe("http://127.0.0.1:9999");
});
test("provider retries when replacement occurs between its read and cache identity check", () => {
const file = writeFixture(localConfig());
const replacement = `${file}.replacement`;
writeFileSync(replacement, stringify(localConfig({ publicUrl: "http://127.0.0.1:9999" })), "utf8");
const provider = createAuthenticationConfigProvider(file);
readHook.callback = () => renameSync(replacement, file);
expect(provider.current().value.publicUrl).toBe("http://127.0.0.1:9999");
});
test("rejects input larger than one MiB", () => {
const file = writeFixture(`${"#".repeat(1024 * 1024)}\n`);
expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid");