feat(auth): verify local ThothII users in the backend
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
closeSync,
|
||||
constants,
|
||||
fstatSync,
|
||||
lstatSync,
|
||||
openSync,
|
||||
readSync,
|
||||
realpathSync,
|
||||
} from "node:fs";
|
||||
import type { Stats } from "node:fs";
|
||||
import { dirname, isAbsolute, normalize } from "node:path";
|
||||
import { parseDocument } from "yaml";
|
||||
import { z } from "zod";
|
||||
import { isValidPasswordHash, verifyPassword, verifyWithDummy } from "./password.js";
|
||||
import type { Role } from "./types.js";
|
||||
|
||||
const MAX_USERS_YAML_BYTES = 1 << 20;
|
||||
const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._@-]{2,63}$/;
|
||||
const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const ROLES = ["user", "admin"] as const;
|
||||
const invalid = (): Error => new Error("local_user_registry_invalid");
|
||||
|
||||
export interface LocalUserRecord {
|
||||
id: string;
|
||||
username: string;
|
||||
normalizedUsername: string;
|
||||
displayName?: string;
|
||||
passwordHash: string;
|
||||
roles: readonly Role[];
|
||||
enabled: boolean;
|
||||
authRevision: number;
|
||||
}
|
||||
|
||||
export interface LocalUserRegistry {
|
||||
findByUsername(username: string): Promise<LocalUserRecord | undefined>;
|
||||
findBySubject(id: string): Promise<LocalUserRecord | undefined>;
|
||||
verify(user: LocalUserRecord | undefined, password: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
interface FileIdentity {
|
||||
dev: number;
|
||||
ino: number;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
const roleSchema = z.enum(ROLES);
|
||||
const userSchema = z.strictObject({
|
||||
id: z.string().regex(UUID_V4_PATTERN),
|
||||
username: z.string().regex(USERNAME_PATTERN),
|
||||
displayName: z.string().optional().refine((value) => value === undefined || !/\p{Cc}/u.test(value)),
|
||||
passwordHash: z.string().refine(isValidPasswordHash),
|
||||
roles: z.array(roleSchema).min(1).superRefine((roles, context) => {
|
||||
if (new Set(roles).size !== roles.length) context.addIssue({ code: "custom", message: "duplicate role" });
|
||||
}),
|
||||
enabled: z.boolean(),
|
||||
authRevision: z.number().int().positive().safe(),
|
||||
});
|
||||
const registrySchema = z.strictObject({ version: z.literal(1), users: z.array(userSchema).min(1) });
|
||||
|
||||
function normalizeUsername(username: string): string {
|
||||
return username.replace(/[A-Z]/g, (character) => character.toLowerCase());
|
||||
}
|
||||
|
||||
function sameIdentity(left: FileIdentity, right: FileIdentity): boolean {
|
||||
return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
|
||||
}
|
||||
|
||||
function validateCanonicalPath(path: string): void {
|
||||
if (typeof path !== "string" || path.length === 0 || path.includes("\0") || !isAbsolute(path) || normalize(path) !== path) throw invalid();
|
||||
const parent = dirname(path);
|
||||
if (realpathSync(parent) !== parent) throw invalid();
|
||||
}
|
||||
|
||||
function validateMetadata(info: { isFile(): boolean; nlink: number; mode: number }): void {
|
||||
if (!info.isFile() || info.nlink !== 1 || (info.mode & 0o7777) !== 0o600) throw invalid();
|
||||
}
|
||||
|
||||
function metadata(info: Stats): FileIdentity {
|
||||
validateMetadata(info);
|
||||
if (info.size < 0 || info.size > MAX_USERS_YAML_BYTES) throw invalid();
|
||||
return { dev: info.dev, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs };
|
||||
}
|
||||
|
||||
function fileIdentity(path: string): FileIdentity {
|
||||
validateCanonicalPath(path);
|
||||
const info = lstatSync(path);
|
||||
return metadata(info as Stats);
|
||||
}
|
||||
|
||||
function readBounded(path: string): { source: string; identity: FileIdentity } {
|
||||
validateCanonicalPath(path);
|
||||
const beforePath = lstatSync(path);
|
||||
const before = metadata(beforePath);
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
const opened = metadata(fstatSync(descriptor) as Stats);
|
||||
if (!sameIdentity(before, opened)) throw invalid();
|
||||
const buffer = Buffer.allocUnsafe(MAX_USERS_YAML_BYTES + 1);
|
||||
let offset = 0;
|
||||
while (offset < buffer.length) {
|
||||
const bytesRead = readSync(descriptor, buffer, offset, buffer.length - offset, null);
|
||||
if (bytesRead === 0) break;
|
||||
offset += bytesRead;
|
||||
}
|
||||
if (offset > MAX_USERS_YAML_BYTES) throw invalid();
|
||||
const after = metadata(fstatSync(descriptor) as Stats);
|
||||
const afterPath = metadata(lstatSync(path) as Stats);
|
||||
if (!sameIdentity(opened, after) || !sameIdentity(after, afterPath)) throw invalid();
|
||||
const source = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset));
|
||||
return { source, identity: after };
|
||||
} catch {
|
||||
throw invalid();
|
||||
} finally {
|
||||
if (descriptor !== undefined) {
|
||||
try { closeSync(descriptor); } catch { /* sanitized by design */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseRegistry(source: string): LocalUserRecord[] {
|
||||
try {
|
||||
const document = parseDocument(source, { uniqueKeys: true });
|
||||
if (document.errors.length > 0 || document.warnings.length > 0) throw invalid();
|
||||
const parsed = registrySchema.parse(document.toJSON());
|
||||
const ids = new Set<string>();
|
||||
const usernames = new Set<string>();
|
||||
let enabledAdmin = false;
|
||||
const records = parsed.users.map((user) => {
|
||||
const normalizedUsername = normalizeUsername(user.username);
|
||||
if (ids.has(user.id) || usernames.has(normalizedUsername)) throw invalid();
|
||||
ids.add(user.id);
|
||||
usernames.add(normalizedUsername);
|
||||
if (user.enabled && user.roles.includes("admin")) enabledAdmin = true;
|
||||
return Object.freeze({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
normalizedUsername,
|
||||
...(user.displayName === undefined ? {} : { displayName: user.displayName }),
|
||||
passwordHash: user.passwordHash,
|
||||
roles: Object.freeze([...user.roles]) as readonly Role[],
|
||||
enabled: user.enabled,
|
||||
authRevision: user.authRevision,
|
||||
});
|
||||
});
|
||||
if (!enabledAdmin) throw invalid();
|
||||
return records;
|
||||
} catch {
|
||||
throw invalid();
|
||||
}
|
||||
}
|
||||
|
||||
function load(path: string): { records: LocalUserRecord[]; identity: FileIdentity } {
|
||||
const read = readBounded(path);
|
||||
return { records: parseRegistry(read.source), identity: read.identity };
|
||||
}
|
||||
|
||||
export function createLocalUserRegistry(usersPath: string): LocalUserRegistry {
|
||||
let cached: { records: LocalUserRecord[]; identity: FileIdentity } | undefined;
|
||||
|
||||
function current(): LocalUserRecord[] {
|
||||
try {
|
||||
const before = fileIdentity(usersPath);
|
||||
if (cached && sameIdentity(cached.identity, before)) return cached.records;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const loaded = load(usersPath);
|
||||
if (sameIdentity(loaded.identity, fileIdentity(usersPath))) {
|
||||
cached = loaded;
|
||||
return loaded.records;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
throw invalid();
|
||||
}
|
||||
throw invalid();
|
||||
}
|
||||
|
||||
return {
|
||||
async findByUsername(username: string): Promise<LocalUserRecord | undefined> {
|
||||
const normalized = normalizeUsername(username);
|
||||
return current().find((user) => user.normalizedUsername === normalized);
|
||||
},
|
||||
async findBySubject(id: string): Promise<LocalUserRecord | undefined> {
|
||||
return current().find((user) => user.id === id);
|
||||
},
|
||||
async verify(user: LocalUserRecord | undefined, password: string): Promise<boolean> {
|
||||
if (!user || !user.enabled) {
|
||||
verifyWithDummy(password);
|
||||
return false;
|
||||
}
|
||||
return verifyPassword(password, user.passwordHash);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { argon2Sync, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
const MAXIMUM_PHC_BYTES = 256;
|
||||
const MAXIMUM_MEMORY_KIB = 256 * 1024;
|
||||
const MAXIMUM_PASSES = 10;
|
||||
const MAXIMUM_PARALLELISM = 4;
|
||||
const MINIMUM_SALT_BYTES = 16;
|
||||
const MAXIMUM_SALT_BYTES = 64;
|
||||
const MINIMUM_KEY_BYTES = 16;
|
||||
const MAXIMUM_KEY_BYTES = 64;
|
||||
const MINIMUM_PASSWORD_BYTES = 12;
|
||||
const MAXIMUM_PASSWORD_BYTES = 1024;
|
||||
|
||||
interface Argon2Parameters {
|
||||
memory: number;
|
||||
passes: number;
|
||||
parallelism: number;
|
||||
salt: Buffer;
|
||||
digest: Buffer;
|
||||
}
|
||||
|
||||
function parseDecimal(value: string, maximum: number): number | undefined {
|
||||
if (!/^\d+$/.test(value) || (value.length > 1 && value[0] === "0") || value.length > 10) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed <= maximum ? parsed : undefined;
|
||||
}
|
||||
|
||||
function decodeRawBase64(value: string, minimum: number, maximum: number): Buffer | undefined {
|
||||
if (!/^[A-Za-z0-9+/]+$/.test(value)) return undefined;
|
||||
const decoded = Buffer.from(value, "base64");
|
||||
if (decoded.length < minimum || decoded.length > maximum) return undefined;
|
||||
if (decoded.toString("base64").replace(/=+$/, "") !== value) return undefined;
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function parsePHC(encoded: string): Argon2Parameters | undefined {
|
||||
if (typeof encoded !== "string" || encoded.length === 0 || Buffer.byteLength(encoded, "utf8") > MAXIMUM_PHC_BYTES) return undefined;
|
||||
const parts = encoded.split("$");
|
||||
if (parts.length !== 6 || parts[0] !== "" || parts[1] !== "argon2id" || parts[2] !== "v=19") return undefined;
|
||||
|
||||
const parameterParts = parts[3].split(",");
|
||||
if (parameterParts.length !== 3 || !parameterParts[0].startsWith("m=") || !parameterParts[1].startsWith("t=") || !parameterParts[2].startsWith("p=")) return undefined;
|
||||
const memory = parseDecimal(parameterParts[0].slice(2), MAXIMUM_MEMORY_KIB);
|
||||
const passes = parseDecimal(parameterParts[1].slice(2), MAXIMUM_PASSES);
|
||||
const parallelism = parseDecimal(parameterParts[2].slice(2), MAXIMUM_PARALLELISM);
|
||||
if (memory === undefined || memory < 8 || passes === undefined || passes === 0 || parallelism === undefined || parallelism === 0 || memory < 8 * parallelism) return undefined;
|
||||
|
||||
const salt = decodeRawBase64(parts[4], MINIMUM_SALT_BYTES, MAXIMUM_SALT_BYTES);
|
||||
const digest = decodeRawBase64(parts[5], MINIMUM_KEY_BYTES, MAXIMUM_KEY_BYTES);
|
||||
if (!salt || !digest) return undefined;
|
||||
return { memory, passes, parallelism, salt, digest };
|
||||
}
|
||||
|
||||
function passwordBytes(password: string): Buffer | undefined {
|
||||
if (typeof password !== "string") return undefined;
|
||||
const bytes = Buffer.from(password, "utf8");
|
||||
return bytes.length >= MINIMUM_PASSWORD_BYTES && bytes.length <= MAXIMUM_PASSWORD_BYTES ? bytes : undefined;
|
||||
}
|
||||
|
||||
export function isValidPasswordHash(encoded: string): boolean {
|
||||
return parsePHC(encoded) !== undefined;
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, encoded: string): boolean {
|
||||
const parameters = parsePHC(encoded);
|
||||
const message = passwordBytes(password);
|
||||
if (!message || !parameters) return false;
|
||||
try {
|
||||
const derived = argon2Sync("argon2id", {
|
||||
message,
|
||||
nonce: parameters.salt,
|
||||
memory: parameters.memory,
|
||||
passes: parameters.passes,
|
||||
parallelism: parameters.parallelism,
|
||||
tagLength: parameters.digest.length,
|
||||
});
|
||||
return derived.length === parameters.digest.length && timingSafeEqual(derived, parameters.digest);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const DUMMY_PASSWORD = "thothii-process-local-dummy-password";
|
||||
const DUMMY_SALT = randomBytes(MINIMUM_SALT_BYTES);
|
||||
const DUMMY_DIGEST = argon2Sync("argon2id", {
|
||||
message: Buffer.from(DUMMY_PASSWORD, "utf8"),
|
||||
nonce: DUMMY_SALT,
|
||||
memory: 65536,
|
||||
passes: 3,
|
||||
parallelism: 1,
|
||||
tagLength: 32,
|
||||
});
|
||||
const DUMMY_HASH = `$argon2id$v=19$m=65536,t=3,p=1$${DUMMY_SALT.toString("base64").replace(/=+$/, "")}$${DUMMY_DIGEST.toString("base64").replace(/=+$/, "")}`;
|
||||
|
||||
export function verifyWithDummy(password: string): void {
|
||||
verifyPassword(passwordBytes(password) ? password : DUMMY_PASSWORD, DUMMY_HASH);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { verifyPassword } from "../src/auth/password.js";
|
||||
|
||||
interface Argon2Vector {
|
||||
password: string;
|
||||
phc: string;
|
||||
}
|
||||
|
||||
const vectors = JSON.parse(readFileSync(
|
||||
resolve(import.meta.dirname, "fixtures/argon2id-vectors.json"),
|
||||
"utf8",
|
||||
)) as Argon2Vector[];
|
||||
|
||||
describe("local Argon2id password verification", () => {
|
||||
test("accepts every committed Go-generated vector", () => {
|
||||
expect(vectors.length).toBeGreaterThan(0);
|
||||
for (const vector of vectors) {
|
||||
expect(verifyPassword(vector.password, vector.phc)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a one-byte password change", () => {
|
||||
for (const vector of vectors) {
|
||||
expect(verifyPassword(`${vector.password}!`, vector.phc)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects malformed and oversized PHC parameters before Argon2 allocation", () => {
|
||||
const password = vectors[0].password;
|
||||
const digest = vectors[0].phc.split("$")[5];
|
||||
const salt = vectors[0].phc.split("$")[4];
|
||||
const cases = [
|
||||
`$argon2id$v=19$m=262145,t=1,p=1$${salt}$${digest}`,
|
||||
`$argon2id$v=19$m=65536,t=11,p=1$${salt}$${digest}`,
|
||||
`$argon2id$v=19$m=65536,t=3,p=5$${salt}$${digest}`,
|
||||
`$argon2id$v=19$m=65536,t=3,p=1$${salt}$${"A".repeat(88)}`,
|
||||
`$argon2id$v=19$m=65536,t=3,p=1$${salt}=$${digest}`,
|
||||
];
|
||||
|
||||
for (const phc of cases) {
|
||||
expect(() => verifyPassword(password, phc)).not.toThrow();
|
||||
expect(verifyPassword(password, phc)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
renameSync,
|
||||
realpathSync,
|
||||
symlinkSync,
|
||||
unlinkSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
linkSync,
|
||||
} from "node:fs";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { createLocalUserRegistry } from "../src/auth/local-registry.js";
|
||||
|
||||
const password = "correct horse battery staple";
|
||||
const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
|
||||
const adminId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8";
|
||||
const userId = "7ba7b810-9dad-4ed1-80b4-00c04fd430c8";
|
||||
|
||||
const createdRoots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of createdRoots.splice(0)) {
|
||||
for (const name of ["users.yaml", "users-link.yaml", "users-target.yaml", "replacement.yaml"]) {
|
||||
const path = join(root, name);
|
||||
if (existsSync(path) || lstatMaybe(path)) unlinkSync(path);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function lstatMaybe(path: string): boolean {
|
||||
try {
|
||||
lstatSync(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function root(): string {
|
||||
const path = mkdtempSync(join(realpathSync(tmpdir()), "thothii-local-registry-"));
|
||||
chmodSync(path, 0o700);
|
||||
createdRoots.push(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
function userYaml(options: {
|
||||
id?: string;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
enabled?: boolean;
|
||||
role?: "user" | "admin";
|
||||
} = {}): string {
|
||||
return [
|
||||
` - id: ${options.id ?? adminId}`,
|
||||
` username: ${options.username ?? "Admin"}`,
|
||||
` displayName: ${options.displayName ?? "Admin"}`,
|
||||
` passwordHash: ${passwordHash}`,
|
||||
` roles:`,
|
||||
` - ${options.role ?? "admin"}`,
|
||||
` enabled: ${options.enabled ?? true}`,
|
||||
` authRevision: 1`,
|
||||
].join("\n") + "\n";
|
||||
}
|
||||
|
||||
function registryYaml(users: string): string {
|
||||
return `version: 1\nusers:\n${users}`;
|
||||
}
|
||||
|
||||
function writeRegistry(contents: string, file = "users.yaml"): { root: string; path: string } {
|
||||
const directory = root();
|
||||
const path = join(directory, file);
|
||||
writeFileSync(path, contents, { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(path, 0o600);
|
||||
return { root: directory, path };
|
||||
}
|
||||
|
||||
async function expectInvalid(operation: Promise<unknown>, secrets: string[] = []): Promise<void> {
|
||||
try {
|
||||
await operation;
|
||||
throw new Error("operation unexpectedly succeeded");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
expect(message).toBe("local_user_registry_invalid");
|
||||
for (const secret of secrets) expect(message).not.toContain(secret);
|
||||
}
|
||||
}
|
||||
|
||||
describe("local user registry", () => {
|
||||
test("reads known fields, performs case-insensitive lookup, and verifies passwords", async () => {
|
||||
const fixture = writeRegistry(registryYaml(userYaml({ displayName: "Local administrator" })));
|
||||
const registry = createLocalUserRegistry(fixture.path);
|
||||
|
||||
await expect(registry.findByUsername("aDmIn")).resolves.toMatchObject({
|
||||
id: adminId,
|
||||
username: "Admin",
|
||||
normalizedUsername: "admin",
|
||||
displayName: "Local administrator",
|
||||
passwordHash,
|
||||
roles: ["admin"],
|
||||
enabled: true,
|
||||
authRevision: 1,
|
||||
});
|
||||
await expect(registry.findBySubject(adminId)).resolves.toMatchObject({ username: "Admin" });
|
||||
await expect(registry.verify(await registry.findByUsername("admin"), password)).resolves.toBe(true);
|
||||
await expect(registry.verify(await registry.findByUsername("admin"), `${password}!`)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test("uses a dummy verification path for unknown and disabled users", async () => {
|
||||
const fixture = writeRegistry(registryYaml(userYaml() + userYaml({
|
||||
id: userId,
|
||||
username: "operator",
|
||||
role: "user",
|
||||
enabled: false,
|
||||
})));
|
||||
const registry = createLocalUserRegistry(fixture.path);
|
||||
|
||||
await expect(registry.verify(undefined, password)).resolves.toBe(false);
|
||||
await expect(registry.verify(await registry.findByUsername("operator"), password)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["duplicate normalized usernames", registryYaml(userYaml() + userYaml({ id: userId, username: "admin" }))],
|
||||
["duplicate IDs", registryYaml(userYaml() + userYaml({ username: "operator" }))],
|
||||
["unknown YAML fields", `${registryYaml(userYaml())}unexpected: true\n`],
|
||||
])("rejects %s", async (_name, contents) => {
|
||||
const fixture = writeRegistry(contents);
|
||||
await expectInvalid(createLocalUserRegistry(fixture.path).findByUsername("admin"), ["admin", passwordHash, fixture.path]);
|
||||
});
|
||||
|
||||
test.each(["symlink", "hard link", "mode wider than 0600", "file larger than 1 MiB"])(
|
||||
"rejects unsafe %s registry metadata",
|
||||
async (kind) => {
|
||||
const fixture = writeRegistry(registryYaml(userYaml()));
|
||||
if (kind === "symlink") {
|
||||
const target = join(fixture.root, "users-target.yaml");
|
||||
renameSync(fixture.path, target);
|
||||
symlinkSync(target, fixture.path);
|
||||
} else if (kind === "hard link") {
|
||||
linkSync(fixture.path, join(fixture.root, "users-link.yaml"));
|
||||
} else if (kind === "mode wider than 0600") {
|
||||
chmodSync(fixture.path, 0o640);
|
||||
} else {
|
||||
writeFileSync(fixture.path, "#".repeat((1 << 20) + 1), { encoding: "utf8", mode: 0o600 });
|
||||
}
|
||||
await expectInvalid(createLocalUserRegistry(fixture.path).findByUsername("admin"), ["admin", passwordHash, fixture.path]);
|
||||
},
|
||||
);
|
||||
|
||||
test("reloads a same-size atomic replacement with changed metadata", async () => {
|
||||
const fixture = writeRegistry(registryYaml(userYaml({ displayName: "Admin" })));
|
||||
const registry = createLocalUserRegistry(fixture.path);
|
||||
await expect(registry.findByUsername("admin")).resolves.toMatchObject({ displayName: "Admin" });
|
||||
|
||||
const replacement = join(fixture.root, "replacement.yaml");
|
||||
writeFileSync(replacement, registryYaml(userYaml({ displayName: "Owner" })), { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(replacement, 0o600);
|
||||
utimesSync(replacement, new Date("2035-01-01T00:00:00Z"), new Date("2035-01-01T00:00:00Z"));
|
||||
expect(lstatSync(replacement).size).toBe(lstatSync(fixture.path).size);
|
||||
renameSync(replacement, fixture.path);
|
||||
|
||||
await expect(registry.findByUsername("admin")).resolves.toMatchObject({ displayName: "Owner" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user