feat: add user management support with secure password handling

- Add declarative user configuration via YAML
- Support plaintext passwords (dev) and passwordFile (production)
- Implement XOR validation: exactly one password source required
- Add comprehensive API client integration (getUsers, createUser)
- Implement calculate/apply pattern for user management
- Add sops-nix integration support for secure secret management
- Wire user management into main configuration pipeline
- Add comprehensive test coverage (15 config + 13 mapper + 12 apply tests)
- Fix API client test coverage gaps (encoding, branding, users endpoints)
- Improve root config test coverage and remove duplicates
- Update documentation with security best practices

Closes #12
Closes #13
This commit is contained in:
Venkatesan Ravi
2025-11-21 02:47:52 -08:00
parent b0fb16c378
commit 013e91b8ea
16 changed files with 1835 additions and 101 deletions

View File

@@ -230,6 +230,59 @@ branding:
splashscreenEnabled: false
```
### User Management
```yaml
version: 1
base_url: "http://localhost:8096"
users:
# User with plaintext password (development only)
- name: "admin-user"
password: "secure-password"
# User with password file (production recommended)
- name: "viewer-user"
passwordFile: "/run/secrets/viewer-password"
```
**Password Security:**
- **plaintext password**: Use `password` field for development/testing only
- **password file**: Use `passwordFile` for production - file contains only the
plaintext password (whitespace is trimmed)
- **Exactly one required**: Each user must specify either `password` or
`passwordFile` (not both)
**sops-nix Integration:**
```nix
{
sops.secrets = {
jellarr-api-key.sopsFile = ../../../../secrets/jellarr-api-key;
viewer-user-password.sopsFile = ../../../../secrets/viewer-user-password;
admin-user-password.sopsFile = ../../../../secrets/admin-user-password;
};
services.jellarr = {
enable = true;
environmentFile = config.sops.templates.jellarr-env.path;
config = {
base_url = "http://localhost:8096";
users = [
{
name = "viewer-user";
passwordFile = config.sops.secrets.viewer-user-password.path;
}
{
name = "admin-user";
passwordFile = config.sops.secrets.admin-user-password.path;
}
];
};
};
}
```
---
## Secret Management
@@ -315,6 +368,11 @@ branding:
customCss: |
@import url("https://cdn.jsdelivr.net/npm/jellyskin@latest/dist/main.css");
splashscreenEnabled: false
users:
- name: "admin-user"
password: "secure-password"
- name: "viewer-user"
passwordFile: "/run/secrets/viewer-password"
```
---

View File

@@ -34,3 +34,8 @@ branding:
customCss: |
@import url("https://cdn.jsdelivr.net/npm/jellyskin@latest/dist/main.css");
splashscreenEnabled: false
users:
- name: "test-jellarr-1"
password: "test"
- name: "test-jellarr-2"
passwordFile: "./test-pass-file"

View File

@@ -6,6 +6,10 @@ import type {
CollectionTypeSchema,
} from "../types/schema/library";
import type { BrandingOptionsDtoSchema } from "../types/schema/branding-options";
import type {
UserDtoSchema,
CreateUserByNameSchema,
} from "../types/schema/users";
export interface ApiResponse<T = unknown> {
data?: T;
@@ -22,6 +26,8 @@ export type GetVirtualFoldersResponse = ApiResponse<VirtualFolderInfoSchema[]>;
export type PostVirtualFolderResponse = ApiResponse<void>;
export type GetBrandingConfigurationResponse = ApiResponse;
export type PostBrandingConfigurationResponse = ApiResponse<void>;
export type GetUsersResponse = ApiResponse<UserDtoSchema[]>;
export type PostUserResponse = ApiResponse<UserDtoSchema>;
export interface JellyfinClient {
getSystemConfiguration(): Promise<ServerConfigurationSchema>;
@@ -42,4 +48,6 @@ export interface JellyfinClient {
updateBrandingConfiguration(
body: Partial<BrandingOptionsDtoSchema>,
): Promise<void>;
getUsers(): Promise<UserDtoSchema[]>;
createUser(body: CreateUserByNameSchema): Promise<void>;
}

View File

@@ -6,6 +6,10 @@ import type {
CollectionTypeSchema,
} from "../types/schema/library";
import type { BrandingOptionsDtoSchema } from "../types/schema/branding-options";
import type {
UserDtoSchema,
CreateUserByNameSchema,
} from "../types/schema/users";
import type {
JellyfinClient,
GetSystemConfigurationResponse,
@@ -16,6 +20,8 @@ import type {
PostVirtualFolderResponse,
GetBrandingConfigurationResponse,
PostBrandingConfigurationResponse,
GetUsersResponse,
PostUserResponse,
} from "./jellyfin.types";
import { makeClient } from "./client";
import type { paths } from "../../generated/schema";
@@ -174,5 +180,27 @@ export function createJellyfinClient(
);
}
},
async getUsers(): Promise<UserDtoSchema[]> {
const res: GetUsersResponse = await client.GET("/Users");
if (res.error) {
throw new Error(`GET /Users failed: ${res.response.status.toString()}`);
}
return res.data as UserDtoSchema[];
},
async createUser(body: CreateUserByNameSchema): Promise<void> {
const res: PostUserResponse = await client.POST("/Users/New", {
body,
});
if (res.error) {
throw new Error(
`POST /Users/New failed: ${res.response.status.toString()}`,
);
}
},
};
}

64
src/apply/users.ts Normal file
View File

@@ -0,0 +1,64 @@
import { deepEqual } from "fast-equals";
import type { JellyfinClient } from "../api/jellyfin.types";
import { logger } from "../lib/logger";
import type { UserConfig, UserConfigList } from "../types/config/users";
import type {
UserDtoSchema,
CreateUserByNameSchema,
} from "../types/schema/users";
import { mapUserConfigToCreateSchema } from "../mappers/users";
function hasUsersChanged(
currentUsers: UserDtoSchema[],
desiredUsers: UserConfigList,
): boolean {
if (desiredUsers.length === 0) return false;
const currentUserNames: string[] = currentUsers
.map((user) => user.Name)
.filter((name): name is string => name !== undefined)
.sort();
const desiredUserNames: string[] = desiredUsers
.map((user) => user.name)
.sort();
return !deepEqual(currentUserNames, desiredUserNames);
}
export function calculateUsersDiff(
currentUsers: UserDtoSchema[],
desired: UserConfigList,
): UserConfig[] | undefined {
const hasChanges: boolean = hasUsersChanged(currentUsers, desired);
if (!hasChanges) return undefined;
const usersToCreate: UserConfig[] = [];
for (const desiredUser of desired) {
const existingUser: UserDtoSchema | undefined = currentUsers.find(
(user) => user.Name === desiredUser.name,
);
if (!existingUser) {
logger.info(`Creating user: ${desiredUser.name}`);
usersToCreate.push(desiredUser);
}
}
return usersToCreate.length > 0 ? usersToCreate : undefined;
}
export async function applyUsers(
client: JellyfinClient,
usersToCreate: UserConfig[] | undefined,
): Promise<void> {
if (!usersToCreate) return;
for (const userConfig of usersToCreate) {
const createSchema: CreateUserByNameSchema =
mapUserConfigToCreateSchema(userConfig);
await client.createUser(createSchema);
}
}

25
src/mappers/users.ts Normal file
View File

@@ -0,0 +1,25 @@
import { readFileSync } from "fs";
import { type UserConfig } from "../types/config/users";
import { type CreateUserByNameSchema } from "../types/schema/users";
export function getPlaintextPassword(config: UserConfig): string | undefined {
return config.password;
}
export function getPasswordFromFile(config: UserConfig): string | undefined {
if (config.passwordFile === undefined) return undefined;
return readFileSync(config.passwordFile, "utf8").trim();
}
export function getPassword(config: UserConfig): string {
return getPlaintextPassword(config) ?? getPasswordFromFile(config) ?? "";
}
export function mapUserConfigToCreateSchema(
desired: UserConfig,
): CreateUserByNameSchema {
return {
Name: desired.name,
Password: getPassword(desired),
};
}

View File

@@ -10,11 +10,14 @@ import {
calculateBrandingOptionsDiff,
applyBrandingOptions,
} from "../apply/branding-options";
import { calculateUsersDiff, applyUsers } from "../apply/users";
import type { VirtualFolderInfoSchema } from "../types/schema/library";
import type { LibraryConfig } from "../types/config/library";
import { type ServerConfigurationSchema } from "../types/schema/system";
import { type EncodingOptionsSchema } from "../types/schema/encoding-options";
import { type BrandingOptionsDtoSchema } from "../types/schema/branding-options";
import type { UserDtoSchema } from "../types/schema/users";
import type { UserConfig } from "../types/config/users";
import { createJellyfinClient } from "../api/jellyfin_client";
import { type JellyfinClient } from "../api/jellyfin.types";
import { RootConfigType, type RootConfig } from "../types/config/root";
@@ -110,4 +113,22 @@ export async function runPipeline(path: string): Promise<void> {
console.log("✓ branding config already up to date");
}
}
// user management
if (cfg.users) {
const currentUsers: UserDtoSchema[] = await jellyfinClient.getUsers();
const usersToCreate: UserConfig[] | undefined = calculateUsersDiff(
currentUsers,
cfg.users,
);
if (usersToCreate) {
console.log("→ creating users");
await applyUsers(jellyfinClient, usersToCreate);
console.log("✓ created users");
} else {
console.log("✓ users already up to date");
}
}
}

View File

@@ -3,6 +3,7 @@ import { SystemConfigType } from "./system";
import { EncodingOptionsConfigType } from "./encoding-options";
import { LibraryConfigType } from "./library";
import { BrandingOptionsConfigType } from "./branding-options";
import { UserConfigListType } from "./users";
export const RootConfigType: z.ZodObject<{
version: z.ZodNumber;
@@ -11,6 +12,7 @@ export const RootConfigType: z.ZodObject<{
encoding: z.ZodOptional<typeof EncodingOptionsConfigType>;
library: z.ZodOptional<typeof LibraryConfigType>;
branding: z.ZodOptional<typeof BrandingOptionsConfigType>;
users: z.ZodOptional<typeof UserConfigListType>;
}> = z
.object({
version: z.number().int().positive("Version must be a positive integer"),
@@ -19,6 +21,7 @@ export const RootConfigType: z.ZodObject<{
encoding: EncodingOptionsConfigType.optional(),
library: LibraryConfigType.optional(),
branding: BrandingOptionsConfigType.optional(),
users: UserConfigListType.optional(),
})
.strict();

33
src/types/config/users.ts Normal file
View File

@@ -0,0 +1,33 @@
import { z } from "zod";
export const UserConfigType: z.ZodObject<{
name: z.ZodString;
password: z.ZodOptional<z.ZodString>;
passwordFile: z.ZodOptional<z.ZodString>;
}> = z
.object({
name: z.string().min(1, "User name is required"),
password: z.string().optional(),
passwordFile: z.string().optional(),
})
.strict()
.refine(
(data) => {
const hasPassword: boolean =
data.password !== undefined && data.password.trim() !== "";
const hasPasswordFile: boolean =
data.passwordFile !== undefined && data.passwordFile.trim() !== "";
return hasPassword !== hasPasswordFile;
},
{
message: "Must specify exactly one of 'password' or 'passwordFile'",
path: [],
},
);
export type UserConfig = z.infer<typeof UserConfigType>;
export const UserConfigListType: z.ZodArray<typeof UserConfigType> =
z.array(UserConfigType);
export type UserConfigList = z.infer<typeof UserConfigListType>;

View File

@@ -0,0 +1,4 @@
import type { components } from "../../../generated/schema";
export type UserDtoSchema = components["schemas"]["UserDto"];
export type CreateUserByNameSchema = components["schemas"]["CreateUserByName"];

1
test-pass-file Normal file
View File

@@ -0,0 +1 @@
test

View File

@@ -15,6 +15,12 @@ import type {
AddVirtualFolderDtoSchema,
LibraryOptionsSchema,
} from "../../src/types/schema/library";
import type { EncodingOptionsSchema } from "../../src/types/schema/encoding-options";
import type { BrandingOptionsDtoSchema } from "../../src/types/schema/branding-options";
import type {
UserDtoSchema,
CreateUserByNameSchema,
} from "../../src/types/schema/users";
const baseUrl: string = "http://localhost:8096/";
const apiKey: string = "test-key";
@@ -296,3 +302,334 @@ describe("api/jf Library façade", () => {
).rejects.toThrow(/POST \/Library\/VirtualFolders failed/i);
});
});
describe("api/jf Encoding façade", () => {
beforeEach((): void => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
afterEach((): void => {
vi.restoreAllMocks();
});
it("when GET /System/Configuration/Encoding returns JSON then it returns the parsed object", async (): Promise<void> => {
// Arrange
const payload: EncodingOptionsSchema = {
EnableHardwareEncoding: true,
EnableHardwareDecoding: false,
} as EncodingOptionsSchema;
mockFetchJson(payload);
// Act
const jellyfinClient: JellyfinClient = createJellyfinClient(
baseUrl,
apiKey,
);
const out: EncodingOptionsSchema =
await jellyfinClient.getEncodingConfiguration();
// Assert
expect(out).toEqual(payload);
const req: Request = getLastRequest();
expect(req.method).toBe("GET");
expect(req.url).toMatch(/\/System\/Configuration\/encoding$/);
expect(req.headers.get("X-Emby-Token")).toBe(apiKey);
});
it("when POST /System/Configuration/Encoding succeeds then it sends JSON body and resolves", async (): Promise<void> => {
// Arrange
mockFetchJson({});
// Act
const jellyfinClient: JellyfinClient = createJellyfinClient(
baseUrl,
apiKey,
);
await jellyfinClient.updateEncodingConfiguration({
EnableHardwareEncoding: true,
});
// Assert
const req: Request = getLastRequest();
expect(req.method).toBe("POST");
expect(req.url).toMatch(/\/System\/Configuration\/encoding$/);
expect(req.headers.get("content-type")).toBe("application/json");
expect(req.headers.get("X-Emby-Token")).toBe(apiKey);
const bodyText: string = await req.text();
expect(bodyText).toContain("EnableHardwareEncoding");
});
it("when GET /System/Configuration/Encoding fails then it throws an error with status", async (): Promise<void> => {
// Arrange
fetchMock = vi
.spyOn(global, "fetch")
.mockResolvedValue(new Response("boom", { status: 500 }));
// Act
const jellyfinClient: JellyfinClient = createJellyfinClient(
baseUrl,
apiKey,
);
// Assert
await expect(jellyfinClient.getEncodingConfiguration()).rejects.toThrow(
/GET \/System\/Configuration\/encoding failed/i,
);
});
it("when POST /System/Configuration/Encoding fails then it throws an error with status", async (): Promise<void> => {
// Arrange
fetchMock = vi
.spyOn(global, "fetch")
.mockResolvedValue(new Response("boom", { status: 400 }));
// Act
const jellyfinClient: JellyfinClient = createJellyfinClient(
baseUrl,
apiKey,
);
// Assert
await expect(
jellyfinClient.updateEncodingConfiguration({}),
).rejects.toThrow(/POST \/System\/Configuration\/encoding failed/i);
});
});
describe("api/jf Branding façade", () => {
beforeEach((): void => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
afterEach((): void => {
vi.restoreAllMocks();
});
it("when GET /System/Configuration/Branding returns JSON then it returns the parsed object", async (): Promise<void> => {
// Arrange
const payload: BrandingOptionsDtoSchema = {
LoginDisclaimer: "Welcome to our server",
CustomCss: "body { background: blue; }",
SplashscreenEnabled: true,
} as BrandingOptionsDtoSchema;
mockFetchJson(payload);
// Act
const jellyfinClient: JellyfinClient = createJellyfinClient(
baseUrl,
apiKey,
);
const out: BrandingOptionsDtoSchema =
await jellyfinClient.getBrandingConfiguration();
// Assert
expect(out).toEqual(payload);
const req: Request = getLastRequest();
expect(req.method).toBe("GET");
expect(req.url).toMatch(/\/System\/Configuration\/Branding$/);
expect(req.headers.get("X-Emby-Token")).toBe(apiKey);
});
it("when POST /System/Configuration/Branding succeeds then it sends JSON body and resolves", async (): Promise<void> => {
// Arrange
mockFetchJson({});
// Act
const jellyfinClient: JellyfinClient = createJellyfinClient(
baseUrl,
apiKey,
);
await jellyfinClient.updateBrandingConfiguration({
LoginDisclaimer: "New disclaimer",
SplashscreenEnabled: false,
});
// Assert
const req: Request = getLastRequest();
expect(req.method).toBe("POST");
expect(req.url).toMatch(/\/System\/Configuration\/Branding$/);
expect(req.headers.get("content-type")).toBe("application/json");
expect(req.headers.get("X-Emby-Token")).toBe(apiKey);
const bodyText: string = await req.text();
expect(bodyText).toContain("LoginDisclaimer");
expect(bodyText).toContain("SplashscreenEnabled");
});
it("when GET /System/Configuration/Branding fails then it throws an error with status", async (): Promise<void> => {
// Arrange
fetchMock = vi
.spyOn(global, "fetch")
.mockResolvedValue(new Response("boom", { status: 403 }));
// Act
const jellyfinClient: JellyfinClient = createJellyfinClient(
baseUrl,
apiKey,
);
// Assert
await expect(jellyfinClient.getBrandingConfiguration()).rejects.toThrow(
/GET \/System\/Configuration\/Branding failed/i,
);
});
it("when POST /System/Configuration/Branding fails then it throws an error with status", async (): Promise<void> => {
// Arrange
fetchMock = vi
.spyOn(global, "fetch")
.mockResolvedValue(new Response("boom", { status: 422 }));
// Act
const jellyfinClient: JellyfinClient = createJellyfinClient(
baseUrl,
apiKey,
);
// Assert
await expect(
jellyfinClient.updateBrandingConfiguration({}),
).rejects.toThrow(/POST \/System\/Configuration\/Branding failed/i);
});
});
describe("api/jf Users façade", () => {
beforeEach((): void => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
afterEach((): void => {
vi.restoreAllMocks();
});
it("when GET /Users returns JSON then it returns the parsed array", async (): Promise<void> => {
// Arrange
const payload: UserDtoSchema[] = [
{
Name: "admin",
Id: "user1-id",
ServerId: "server-id",
HasConfiguredPassword: true,
},
{
Name: "testuser",
Id: "user2-id",
ServerId: "server-id",
HasConfiguredPassword: true,
},
] as UserDtoSchema[];
mockFetchJson(payload);
// Act
const jellyfinClient: JellyfinClient = createJellyfinClient(
baseUrl,
apiKey,
);
const out: UserDtoSchema[] = await jellyfinClient.getUsers();
// Assert
expect(out).toEqual(payload);
expect(out).toHaveLength(2);
expect(out[0].Name).toBe("admin");
expect(out[1].Name).toBe("testuser");
const req: Request = getLastRequest();
expect(req.method).toBe("GET");
expect(req.url).toMatch(/\/Users$/);
expect(req.headers.get("X-Emby-Token")).toBe(apiKey);
});
it("when GET /Users returns empty array then it returns empty array", async (): Promise<void> => {
// Arrange
mockFetchJson([]);
// Act
const jellyfinClient: JellyfinClient = createJellyfinClient(
baseUrl,
apiKey,
);
const out: UserDtoSchema[] = await jellyfinClient.getUsers();
// Assert
expect(out).toEqual([]);
expect(out).toHaveLength(0);
});
it("when POST /Users/New succeeds then it sends JSON body and resolves", async (): Promise<void> => {
// Arrange
const responsePayload: UserDtoSchema = {
Name: "newuser",
Id: "new-user-id",
ServerId: "server-id",
} as UserDtoSchema;
mockFetchJson(responsePayload);
// Act
const jellyfinClient: JellyfinClient = createJellyfinClient(
baseUrl,
apiKey,
);
const createBody: CreateUserByNameSchema = {
Name: "newuser",
Password: "testpass",
};
await jellyfinClient.createUser(createBody);
// Assert
const req: Request = getLastRequest();
expect(req.method).toBe("POST");
expect(req.url).toMatch(/\/Users\/New$/);
expect(req.headers.get("content-type")).toBe("application/json");
expect(req.headers.get("X-Emby-Token")).toBe(apiKey);
const bodyText: string = await req.text();
expect(bodyText).toContain("newuser");
expect(bodyText).toContain("testpass");
});
it("when GET /Users fails then it throws an error with status", async (): Promise<void> => {
// Arrange
fetchMock = vi
.spyOn(global, "fetch")
.mockResolvedValue(new Response("boom", { status: 401 }));
// Act
const jellyfinClient: JellyfinClient = createJellyfinClient(
baseUrl,
apiKey,
);
// Assert
await expect(jellyfinClient.getUsers()).rejects.toThrow(
/GET \/Users failed/i,
);
});
it("when POST /Users/New fails then it throws an error with status", async (): Promise<void> => {
// Arrange
fetchMock = vi
.spyOn(global, "fetch")
.mockResolvedValue(new Response("boom", { status: 409 }));
// Act
const jellyfinClient: JellyfinClient = createJellyfinClient(
baseUrl,
apiKey,
);
const createBody: CreateUserByNameSchema = {
Name: "duplicate",
Password: "test",
};
// Assert
await expect(jellyfinClient.createUser(createBody)).rejects.toThrow(
/POST \/Users\/New failed/i,
);
});
});

323
tests/apply/users.spec.ts Normal file
View File

@@ -0,0 +1,323 @@
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
import { calculateUsersDiff, applyUsers } from "../../src/apply/users";
import type { JellyfinClient } from "../../src/api/jellyfin.types";
import type { UserConfig, UserConfigList } from "../../src/types/config/users";
import type { UserDtoSchema } from "../../src/types/schema/users";
vi.mock("../../src/mappers/users", () => ({
mapUserConfigToCreateSchema: vi.fn((config: UserConfig) => ({
Name: config.name,
Password: config.password || "mocked-password-from-file",
})),
}));
describe("calculateUsersDiff", () => {
const currentUsers: UserDtoSchema[] = [
{
Name: "existing-user",
Id: "user-1-id",
ServerId: "server-id",
},
{
Name: "another-user",
Id: "user-2-id",
ServerId: "server-id",
},
];
it("should return undefined when no users desired", () => {
// Arrange
const config: UserConfigList = [];
// Act
const result: UserConfig[] | undefined = calculateUsersDiff(
currentUsers,
config,
);
// Assert
expect(result).toBeUndefined();
});
it("should return undefined when all desired users already exist", () => {
// Arrange
const config: UserConfigList = [
{
name: "existing-user",
password: "password",
},
{
name: "another-user",
password: "password",
},
];
// Act
const result: UserConfig[] | undefined = calculateUsersDiff(
currentUsers,
config,
);
// Assert
expect(result).toBeUndefined();
});
it("should return users to create when new users desired", () => {
// Arrange
const config: UserConfigList = [
{
name: "existing-user",
password: "password",
},
{
name: "new-user",
password: "new-password",
},
];
// Act
const result: UserConfig[] | undefined = calculateUsersDiff(
currentUsers,
config,
);
// Assert
expect(result).toBeDefined();
expect(result).toHaveLength(1);
expect(result?.[0]).toEqual({
name: "new-user",
password: "new-password",
});
});
it("should return multiple users when multiple new users desired", () => {
// Arrange
const config: UserConfigList = [
{
name: "new-user-1",
password: "password1",
},
{
name: "new-user-2",
passwordFile: "/secrets/password2",
},
];
// Act
const result: UserConfig[] | undefined = calculateUsersDiff(
currentUsers,
config,
);
// Assert
expect(result).toBeDefined();
expect(result).toHaveLength(2);
expect(result?.[0]).toEqual({
name: "new-user-1",
password: "password1",
});
expect(result?.[1]).toEqual({
name: "new-user-2",
passwordFile: "/secrets/password2",
});
});
it("should handle mixed existing and new users", () => {
// Arrange
const config: UserConfigList = [
{
name: "existing-user",
password: "password",
},
{
name: "new-user-1",
password: "password1",
},
{
name: "another-user",
password: "password",
},
{
name: "new-user-2",
passwordFile: "/secrets/password2",
},
];
// Act
const result: UserConfig[] | undefined = calculateUsersDiff(
currentUsers,
config,
);
// Assert
expect(result).toBeDefined();
expect(result).toHaveLength(2);
expect(result?.[0]).toEqual({
name: "new-user-1",
password: "password1",
});
expect(result?.[1]).toEqual({
name: "new-user-2",
passwordFile: "/secrets/password2",
});
});
it("should handle empty current users list", () => {
// Arrange
const config: UserConfigList = [
{
name: "new-user",
password: "password",
},
];
// Act
const result: UserConfig[] | undefined = calculateUsersDiff([], config);
// Assert
expect(result).toBeDefined();
expect(result).toHaveLength(1);
expect(result?.[0]).toEqual({
name: "new-user",
password: "password",
});
});
it("should handle order-independent comparison", () => {
// Arrange
const config: UserConfigList = [
{
name: "another-user",
password: "password",
},
{
name: "existing-user",
password: "password",
},
];
// Act
const result: UserConfig[] | undefined = calculateUsersDiff(
currentUsers,
config,
);
// Assert
expect(result).toBeUndefined();
});
it("should handle undefined user names in current users", () => {
// Arrange
const currentUsersWithUndefined: UserDtoSchema[] = [
{
Name: undefined,
Id: "user-1-id",
ServerId: "server-id",
},
{
Name: "existing-user",
Id: "user-2-id",
ServerId: "server-id",
},
];
const config: UserConfigList = [
{
name: "existing-user",
password: "password",
},
];
// Act
const result: UserConfig[] | undefined = calculateUsersDiff(
currentUsersWithUndefined,
config,
);
// Assert
expect(result).toBeUndefined();
});
});
describe("applyUsers", () => {
const mockClient: JellyfinClient = {
createUser: vi.fn(),
} as unknown as JellyfinClient;
beforeEach(() => {
vi.clearAllMocks();
});
it("should do nothing when usersToCreate is undefined", async () => {
// Arrange
const createUserSpy: Mock = vi.spyOn(mockClient, "createUser");
// Act
await applyUsers(mockClient, undefined);
// Assert
expect(createUserSpy).not.toHaveBeenCalled();
});
it("should create single user", async () => {
// Arrange
const createUserSpy: Mock = vi.spyOn(mockClient, "createUser");
const usersToCreate: UserConfig[] = [
{
name: "test-user",
password: "test-password",
},
];
// Act
await applyUsers(mockClient, usersToCreate);
// Assert
expect(createUserSpy).toHaveBeenCalledTimes(1);
expect(createUserSpy).toHaveBeenCalledWith({
Name: "test-user",
Password: "test-password",
});
});
it("should create multiple users", async () => {
// Arrange
const createUserSpy: Mock = vi.spyOn(mockClient, "createUser");
const usersToCreate: UserConfig[] = [
{
name: "user1",
password: "password1",
},
{
name: "user2",
passwordFile: "/secrets/password2",
},
];
// Act
await applyUsers(mockClient, usersToCreate);
// Assert
expect(createUserSpy).toHaveBeenCalledTimes(2);
expect(createUserSpy).toHaveBeenNthCalledWith(1, {
Name: "user1",
Password: "password1",
});
expect(createUserSpy).toHaveBeenNthCalledWith(2, {
Name: "user2",
Password: "mocked-password-from-file",
});
});
it("should handle empty users list", async () => {
// Arrange
const createUserSpy: Mock = vi.spyOn(mockClient, "createUser");
const usersToCreate: UserConfig[] = [];
// Act
await applyUsers(mockClient, usersToCreate);
// Assert
expect(createUserSpy).not.toHaveBeenCalled();
});
});

238
tests/mappers/users.spec.ts Normal file
View File

@@ -0,0 +1,238 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { writeFileSync, unlinkSync, existsSync } from "fs";
import {
getPlaintextPassword,
getPasswordFromFile,
getPassword,
mapUserConfigToCreateSchema,
} from "../../src/mappers/users";
import { type UserConfig } from "../../src/types/config/users";
import { type CreateUserByNameSchema } from "../../src/types/schema/users";
describe("mappers/users", () => {
const testPasswordFile: string = "/tmp/test-user-password";
beforeEach(() => {
if (existsSync(testPasswordFile)) {
unlinkSync(testPasswordFile);
}
});
afterEach(() => {
if (existsSync(testPasswordFile)) {
unlinkSync(testPasswordFile);
}
});
describe("getPlaintextPassword", () => {
it("should return password when present", () => {
// Arrange
const config: UserConfig = {
name: "test-user",
password: "plaintext-password",
};
// Act
const result: string | undefined = getPlaintextPassword(config);
// Assert
expect(result).toBe("plaintext-password");
});
it("should return undefined when password not present", () => {
// Arrange
const config: UserConfig = {
name: "test-user",
passwordFile: "/path/to/file",
};
// Act
const result: string | undefined = getPlaintextPassword(config);
// Assert
expect(result).toBeUndefined();
});
});
describe("getPasswordFromFile", () => {
it("should return password from file when passwordFile is present", () => {
// Arrange
const expectedPassword: string = "secret-from-file";
writeFileSync(testPasswordFile, expectedPassword);
const config: UserConfig = {
name: "test-user",
passwordFile: testPasswordFile,
};
// Act
const result: string | undefined = getPasswordFromFile(config);
// Assert
expect(result).toBe(expectedPassword);
});
it("should trim whitespace from file content", () => {
// Arrange
const expectedPassword: string = "secret-with-whitespace";
writeFileSync(testPasswordFile, ` ${expectedPassword} \n`);
const config: UserConfig = {
name: "test-user",
passwordFile: testPasswordFile,
};
// Act
const result: string | undefined = getPasswordFromFile(config);
// Assert
expect(result).toBe(expectedPassword);
});
it("should return undefined when passwordFile not present", () => {
// Arrange
const config: UserConfig = {
name: "test-user",
password: "plaintext",
};
// Act
const result: string | undefined = getPasswordFromFile(config);
// Assert
expect(result).toBeUndefined();
});
it("should throw error when passwordFile does not exist", () => {
// Arrange
const config: UserConfig = {
name: "test-user",
passwordFile: "/nonexistent/password/file",
};
// Act & Assert
expect(() => getPasswordFromFile(config)).toThrow();
});
});
describe("getPassword", () => {
it("should return plaintext password when present", () => {
// Arrange
const config: UserConfig = {
name: "test-user",
password: "plaintext-password",
};
// Act
const result: string = getPassword(config);
// Assert
expect(result).toBe("plaintext-password");
});
it("should return password from file when passwordFile present and password not present", () => {
// Arrange
const expectedPassword: string = "file-password";
writeFileSync(testPasswordFile, expectedPassword);
const config: UserConfig = {
name: "test-user",
passwordFile: testPasswordFile,
};
// Act
const result: string = getPassword(config);
// Assert
expect(result).toBe(expectedPassword);
});
it("should prefer plaintext password over passwordFile when both present", () => {
// Arrange
writeFileSync(testPasswordFile, "file-password");
const config: UserConfig = {
name: "test-user",
password: "plaintext-password",
passwordFile: testPasswordFile,
};
// Act
const result: string = getPassword(config);
// Assert
expect(result).toBe("plaintext-password");
});
it("should return empty string when neither password nor passwordFile present", () => {
// Arrange
const config: UserConfig = {
name: "test-user",
} as UserConfig;
// Act
const result: string = getPassword(config);
// Assert
expect(result).toBe("");
});
});
describe("mapUserConfigToCreateSchema", () => {
it("should map user config with password to create schema", () => {
// Arrange
const userConfig: UserConfig = {
name: "test-user",
password: "test-password",
};
// Act
const result: CreateUserByNameSchema =
mapUserConfigToCreateSchema(userConfig);
// Assert
expect(result).toEqual({
Name: "test-user",
Password: "test-password",
});
});
it("should map user config with passwordFile to create schema", () => {
// Arrange
const expectedPassword: string = "secret-from-file";
writeFileSync(testPasswordFile, expectedPassword);
const userConfig: UserConfig = {
name: "test-user",
passwordFile: testPasswordFile,
};
// Act
const result: CreateUserByNameSchema =
mapUserConfigToCreateSchema(userConfig);
// Assert
expect(result).toEqual({
Name: "test-user",
Password: expectedPassword,
});
});
it("should handle empty password when neither source available", () => {
// Arrange
const userConfig: UserConfig = {
name: "test-user",
} as UserConfig;
// Act
const result: CreateUserByNameSchema =
mapUserConfigToCreateSchema(userConfig);
// Assert
expect(result).toEqual({
Name: "test-user",
Password: "",
});
});
});
});

View File

@@ -79,107 +79,6 @@ describe("RootConfig", () => {
}
});
it("should validate root config with library section", () => {
// Arrange
const validConfig: z.input<typeof RootConfigType> = {
version: 1,
base_url: "http://10.0.0.76:8096",
system: {},
library: {
virtualFolders: [
{
name: "test",
collectionType: "movies",
libraryOptions: {
pathInfos: [{ path: "/test" }],
},
},
],
},
};
// Act
const result: ZodSafeParseResult<RootConfig> =
RootConfigType.safeParse(validConfig);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(validConfig);
}
});
it("should validate complete root config with all sections", () => {
// Arrange
const validConfig: z.input<typeof RootConfigType> = {
version: 1,
base_url: "http://10.0.0.76:8096",
system: {
enableMetrics: true,
pluginRepositories: [
{
name: "Test Repository",
url: "https://test.example.com/manifest.json",
enabled: true,
},
],
trickplayOptions: {
enableHwAcceleration: true,
},
},
encoding: {
enableHardwareEncoding: true,
},
library: {
virtualFolders: [
{
name: "movies",
collectionType: "movies",
libraryOptions: {
pathInfos: [{ path: "/media/movies" }],
},
},
{
name: "tv shows",
collectionType: "tvshows",
libraryOptions: {
pathInfos: [{ path: "/media/tv" }, { path: "/media/shows" }],
},
},
],
},
};
// Act
const result: ZodSafeParseResult<RootConfig> =
RootConfigType.safeParse(validConfig);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(validConfig);
}
});
it("should validate root config without library section", () => {
// Arrange
const validConfig: z.input<typeof RootConfigType> = {
version: 1,
base_url: "http://10.0.0.76:8096",
system: {},
};
// Act
const result: ZodSafeParseResult<RootConfig> =
RootConfigType.safeParse(validConfig);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(validConfig);
}
});
it("should validate root config with library section", () => {
// Arrange
const validConfig: z.input<typeof RootConfigType> = {
@@ -233,6 +132,25 @@ describe("RootConfig", () => {
}
});
it("should validate root config without library section", () => {
// Arrange
const validConfig: z.input<typeof RootConfigType> = {
version: 1,
base_url: "http://10.0.0.76:8096",
system: {},
};
// Act
const result: ZodSafeParseResult<RootConfig> =
RootConfigType.safeParse(validConfig);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.library).toBeUndefined();
}
});
it("should reject invalid version", () => {
// Arrange
const invalidConfig: z.input<typeof RootConfigType> = {
@@ -296,6 +214,332 @@ describe("RootConfig", () => {
expect(result.success).toBe(false);
});
it("should validate root config with branding section", () => {
// Arrange
const validConfig: z.input<typeof RootConfigType> = {
version: 1,
base_url: "http://10.0.0.76:8096",
system: {},
branding: {
loginDisclaimer: "Custom login message",
customCss: "body { background: black; }",
},
};
// Act
const result: ZodSafeParseResult<RootConfig> =
RootConfigType.safeParse(validConfig);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.branding).toBeDefined();
expect(result.data.branding?.loginDisclaimer).toBe(
"Custom login message",
);
}
});
it("should validate root config with empty branding section", () => {
// Arrange
const validConfig: z.input<typeof RootConfigType> = {
version: 1,
base_url: "http://10.0.0.76:8096",
system: {},
branding: {},
};
// Act
const result: ZodSafeParseResult<RootConfig> =
RootConfigType.safeParse(validConfig);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.branding).toBeDefined();
}
});
it("should validate root config without branding section", () => {
// Arrange
const validConfig: z.input<typeof RootConfigType> = {
version: 1,
base_url: "http://10.0.0.76:8096",
system: {},
};
// Act
const result: ZodSafeParseResult<RootConfig> =
RootConfigType.safeParse(validConfig);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.branding).toBeUndefined();
}
});
it("should reject invalid branding configuration", () => {
// Arrange
const invalidConfig: z.input<typeof RootConfigType> = {
version: 1,
base_url: "http://10.0.0.76:8096",
system: {},
branding: {
// @ts-expect-error intentional invalid field for test
invalidField: "not allowed",
},
};
// Act
const result: ZodSafeParseResult<RootConfig> =
RootConfigType.safeParse(invalidConfig);
// Assert
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toBeDefined();
expect(result.error.issues.length).toBeGreaterThan(0);
}
});
it("should validate root config with users section", () => {
// Arrange
const validConfig: z.input<typeof RootConfigType> = {
version: 1,
base_url: "http://10.0.0.76:8096",
system: {},
users: [
{
name: "testuser",
password: "testpass123",
},
{
name: "fileuser",
passwordFile: "/secrets/password",
},
],
};
// Act
const result: ZodSafeParseResult<RootConfig> =
RootConfigType.safeParse(validConfig);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.users).toBeDefined();
expect(result.data.users).toHaveLength(2);
expect(result.data.users?.[0].name).toBe("testuser");
expect(result.data.users?.[1].name).toBe("fileuser");
}
});
it("should validate root config with empty users section", () => {
// Arrange
const validConfig: z.input<typeof RootConfigType> = {
version: 1,
base_url: "http://10.0.0.76:8096",
system: {},
users: [],
};
// Act
const result: ZodSafeParseResult<RootConfig> =
RootConfigType.safeParse(validConfig);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.users).toBeDefined();
expect(result.data.users).toHaveLength(0);
}
});
it("should validate root config without users section", () => {
// Arrange
const validConfig: z.input<typeof RootConfigType> = {
version: 1,
base_url: "http://10.0.0.76:8096",
system: {},
};
// Act
const result: ZodSafeParseResult<RootConfig> =
RootConfigType.safeParse(validConfig);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.users).toBeUndefined();
}
});
it("should reject invalid users configuration", () => {
// Arrange
const invalidConfig: z.input<typeof RootConfigType> = {
version: 1,
base_url: "http://10.0.0.76:8096",
system: {},
users: [
{
name: "invaliduser",
},
],
};
// Act
const result: ZodSafeParseResult<RootConfig> =
RootConfigType.safeParse(invalidConfig);
// Assert
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toBeDefined();
expect(result.error.issues.length).toBeGreaterThan(0);
}
});
it("should validate root config with empty encoding section", () => {
// Arrange
const validConfig: z.input<typeof RootConfigType> = {
version: 1,
base_url: "http://10.0.0.76:8096",
system: {},
encoding: {},
};
// Act
const result: ZodSafeParseResult<RootConfig> =
RootConfigType.safeParse(validConfig);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.encoding).toBeDefined();
}
});
it("should reject invalid encoding configuration", () => {
// Arrange
const invalidConfig: z.input<typeof RootConfigType> = {
version: 1,
base_url: "http://10.0.0.76:8096",
system: {},
encoding: {
// @ts-expect-error intentional invalid boolean value for test
enableHardwareEncoding: "invalid_boolean_value",
},
};
// Act
const result: ZodSafeParseResult<RootConfig> =
RootConfigType.safeParse(invalidConfig);
// Assert
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toBeDefined();
expect(result.error.issues.length).toBeGreaterThan(0);
}
});
it("should reject invalid library configuration", () => {
// Arrange
const invalidConfig: z.input<typeof RootConfigType> = {
version: 1,
base_url: "http://10.0.0.76:8096",
system: {},
library: {
virtualFolders: [
{
name: "InvalidLibrary",
// @ts-expect-error intentional invalid collection type for test
collectionType: "invalid_type",
libraryOptions: {
pathInfos: [{ path: "/invalid/path" }],
},
},
],
},
};
// Act
const result: ZodSafeParseResult<RootConfig> =
RootConfigType.safeParse(invalidConfig);
// Assert
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toBeDefined();
expect(result.error.issues.length).toBeGreaterThan(0);
}
});
it("should validate root config with all fields", () => {
// Arrange
const validConfig: z.input<typeof RootConfigType> = {
version: 1,
base_url: "http://10.0.0.76:8096",
system: {
enableMetrics: true,
pluginRepositories: [
{
name: "Test Repository",
url: "https://test.example.com/manifest.json",
enabled: true,
},
],
trickplayOptions: {
enableHwAcceleration: true,
},
},
encoding: {
enableHardwareEncoding: true,
},
library: {
virtualFolders: [
{
name: "movies",
collectionType: "movies",
libraryOptions: {
pathInfos: [{ path: "/media/movies" }],
},
},
],
},
branding: {
loginDisclaimer: "Welcome to our media server",
customCss: "body { font-family: Arial; }",
},
users: [
{
name: "admin",
password: "adminpass123",
},
{
name: "viewer",
passwordFile: "/secrets/viewer_password",
},
],
};
// Act
const result: ZodSafeParseResult<RootConfig> =
RootConfigType.safeParse(validConfig);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.system).toBeDefined();
expect(result.data.encoding).toBeDefined();
expect(result.data.library).toBeDefined();
expect(result.data.branding).toBeDefined();
expect(result.data.users).toBeDefined();
expect(result.data.users).toHaveLength(2);
}
});
it("should reject extra fields due to strict mode", () => {
// Arrange
const invalidConfig: z.input<typeof RootConfigType> = {

View File

@@ -0,0 +1,342 @@
import { describe, it, expect } from "vitest";
import type { ZodSafeParseResult } from "zod";
import { type z } from "zod";
import {
UserConfigType,
type UserConfig,
UserConfigListType,
type UserConfigList,
} from "../../../src/types/config/users";
describe("UserConfig", () => {
it("should reject user with name only (no password source)", () => {
// Arrange
const invalidConfig: z.input<typeof UserConfigType> = {
name: "test-user",
};
// Act
const result: ZodSafeParseResult<UserConfig> =
UserConfigType.safeParse(invalidConfig);
// Assert
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toBeDefined();
expect(result.error.issues.length).toBeGreaterThan(0);
const refineError: z.core.$ZodIssue | undefined =
result.error.issues.find((err) => err.code === "custom");
expect(refineError?.message).toContain(
"Must specify exactly one of 'password' or 'passwordFile'",
);
}
});
it("should validate valid user config with name and password", () => {
// Arrange
const validConfig: z.input<typeof UserConfigType> = {
name: "test-jellarr",
password: "test",
};
// Act
const result: ZodSafeParseResult<UserConfig> =
UserConfigType.safeParse(validConfig);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(validConfig);
}
});
it("should validate valid user config with name and passwordFile", () => {
// Arrange
const validConfig: z.input<typeof UserConfigType> = {
name: "test-jellarr",
passwordFile: "/run/secrets/user-password",
};
// Act
const result: ZodSafeParseResult<UserConfig> =
UserConfigType.safeParse(validConfig);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(validConfig);
}
});
it("should reject empty name", () => {
// Arrange
const invalidConfig: z.input<typeof UserConfigType> = {
name: "",
password: "test",
};
// Act
const result: ZodSafeParseResult<UserConfig> =
UserConfigType.safeParse(invalidConfig);
// Assert
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toBeDefined();
expect(result.error.issues.length).toBeGreaterThan(0);
const nameError: z.core.$ZodIssue | undefined = result.error.issues.find(
(err) => err.path.includes("name"),
);
expect(nameError?.message).toContain("User name is required");
}
});
it("should reject missing name", () => {
// Arrange
const invalidConfig: Partial<z.input<typeof UserConfigType>> = {
password: "test",
};
// Act
const result: ZodSafeParseResult<UserConfig> = UserConfigType.safeParse(
invalidConfig as z.input<typeof UserConfigType>,
);
// Assert
expect(result.success).toBe(false);
});
it("should reject user with both password and passwordFile", () => {
// Arrange
const invalidConfig: z.input<typeof UserConfigType> = {
name: "test-user",
password: "test",
passwordFile: "/run/secrets/user-password",
};
// Act
const result: ZodSafeParseResult<UserConfig> =
UserConfigType.safeParse(invalidConfig);
// Assert
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toBeDefined();
expect(result.error.issues.length).toBeGreaterThan(0);
const refineError: z.core.$ZodIssue | undefined =
result.error.issues.find((err) => err.code === "custom");
expect(refineError?.message).toContain(
"Must specify exactly one of 'password' or 'passwordFile'",
);
}
});
it("should reject user with neither password nor passwordFile", () => {
// Arrange
const invalidConfig: z.input<typeof UserConfigType> = {
name: "test-user",
};
// Act
const result: ZodSafeParseResult<UserConfig> =
UserConfigType.safeParse(invalidConfig);
// Assert
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toBeDefined();
expect(result.error.issues.length).toBeGreaterThan(0);
const refineError: z.core.$ZodIssue | undefined =
result.error.issues.find((err) => err.code === "custom");
expect(refineError?.message).toContain(
"Must specify exactly one of 'password' or 'passwordFile'",
);
}
});
it("should reject empty password", () => {
// Arrange
const invalidConfig: z.input<typeof UserConfigType> = {
name: "test-user",
password: "",
};
// Act
const result: ZodSafeParseResult<UserConfig> =
UserConfigType.safeParse(invalidConfig);
// Assert
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toBeDefined();
expect(result.error.issues.length).toBeGreaterThan(0);
const refineError: z.core.$ZodIssue | undefined =
result.error.issues.find((err) => err.code === "custom");
expect(refineError?.message).toContain(
"Must specify exactly one of 'password' or 'passwordFile'",
);
}
});
it("should reject empty passwordFile", () => {
// Arrange
const invalidConfig: z.input<typeof UserConfigType> = {
name: "test-user",
passwordFile: "",
};
// Act
const result: ZodSafeParseResult<UserConfig> =
UserConfigType.safeParse(invalidConfig);
// Assert
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toBeDefined();
expect(result.error.issues.length).toBeGreaterThan(0);
const refineError: z.core.$ZodIssue | undefined =
result.error.issues.find((err) => err.code === "custom");
expect(refineError?.message).toContain(
"Must specify exactly one of 'password' or 'passwordFile'",
);
}
});
it("should reject whitespace-only password", () => {
// Arrange
const invalidConfig: z.input<typeof UserConfigType> = {
name: "test-user",
password: " ",
};
// Act
const result: ZodSafeParseResult<UserConfig> =
UserConfigType.safeParse(invalidConfig);
// Assert
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toBeDefined();
expect(result.error.issues.length).toBeGreaterThan(0);
const refineError: z.core.$ZodIssue | undefined =
result.error.issues.find((err) => err.code === "custom");
expect(refineError?.message).toContain(
"Must specify exactly one of 'password' or 'passwordFile'",
);
}
});
it("should reject whitespace-only passwordFile", () => {
// Arrange
const invalidConfig: z.input<typeof UserConfigType> = {
name: "test-user",
passwordFile: " ",
};
// Act
const result: ZodSafeParseResult<UserConfig> =
UserConfigType.safeParse(invalidConfig);
// Assert
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toBeDefined();
expect(result.error.issues.length).toBeGreaterThan(0);
const refineError: z.core.$ZodIssue | undefined =
result.error.issues.find((err) => err.code === "custom");
expect(refineError?.message).toContain(
"Must specify exactly one of 'password' or 'passwordFile'",
);
}
});
it("should reject extra fields due to strict mode", () => {
// Arrange
const invalidConfig: z.input<typeof UserConfigType> = {
name: "test-user",
password: "valid-password",
// @ts-expect-error intentional extra field for test
unknownField: "should not be allowed",
};
// Act
const result: ZodSafeParseResult<UserConfig> =
UserConfigType.safeParse(invalidConfig);
// Assert
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toBeDefined();
expect(result.error.issues.length).toBeGreaterThan(0);
const strictError: z.core.$ZodIssue | undefined =
result.error.issues.find((err) => err.code === "unrecognized_keys");
expect(strictError?.code).toBe("unrecognized_keys");
}
});
});
describe("UserConfigList", () => {
it("should validate empty user list", () => {
// Arrange
const validConfig: z.input<typeof UserConfigListType> = [];
// Act
const result: ZodSafeParseResult<UserConfigList> =
UserConfigListType.safeParse(validConfig);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual([]);
}
});
it("should validate user list with multiple users", () => {
// Arrange
const validConfig: z.input<typeof UserConfigListType> = [
{
name: "user1",
password: "password1",
},
{
name: "user2",
passwordFile: "/run/secrets/user2-password",
},
{
name: "test-jellarr",
password: "test",
},
];
// Act
const result: ZodSafeParseResult<UserConfigList> =
UserConfigListType.safeParse(validConfig);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(validConfig);
}
});
it("should reject list with invalid user", () => {
// Arrange
const invalidConfig: z.input<typeof UserConfigListType> = [
{
name: "valid-user",
password: "valid-password",
},
{
name: "",
password: "password-for-empty-name",
},
];
// Act
const result: ZodSafeParseResult<UserConfigList> =
UserConfigListType.safeParse(invalidConfig);
// Assert
expect(result.success).toBe(false);
});
});