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:
58
README.md
58
README.md
@@ -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"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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
64
src/apply/users.ts
Normal 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
25
src/mappers/users.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
33
src/types/config/users.ts
Normal 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>;
|
||||
4
src/types/schema/users.ts
Normal file
4
src/types/schema/users.ts
Normal 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
1
test-pass-file
Normal file
@@ -0,0 +1 @@
|
||||
test
|
||||
@@ -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
323
tests/apply/users.spec.ts
Normal 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
238
tests/mappers/users.spec.ts
Normal 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: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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> = {
|
||||
|
||||
342
tests/types/config/users.spec.ts
Normal file
342
tests/types/config/users.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user