Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions api/src/core/utils/__test__/safe-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { isSafeModeEnabled } from '@app/core/utils/safe-mode.js';
import { store } from '@app/store/index.js';
import * as stateFileLoader from '@app/store/services/state-file-loader.js';

describe('isSafeModeEnabled', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('returns the safe mode flag already present in the store', () => {
const baseState = store.getState();
vi.spyOn(store, 'getState').mockReturnValue({
...baseState,
emhttp: {
...baseState.emhttp,
var: {
...(baseState.emhttp?.var ?? {}),
safeMode: true,
},
},
});
const loaderSpy = vi.spyOn(stateFileLoader, 'loadStateFileSync');

expect(isSafeModeEnabled()).toBe(true);
expect(loaderSpy).not.toHaveBeenCalled();
});

it('falls back to the synchronous loader when store state is missing', () => {
const baseState = store.getState();
vi.spyOn(store, 'getState').mockReturnValue({
...baseState,
emhttp: {
...baseState.emhttp,
var: {
...(baseState.emhttp?.var ?? {}),
safeMode: undefined as unknown as boolean,
} as typeof baseState.emhttp.var,
} as typeof baseState.emhttp,
} as typeof baseState);
vi.spyOn(stateFileLoader, 'loadStateFileSync').mockReturnValue({
...(baseState.emhttp?.var ?? {}),
safeMode: true,
} as any);

expect(isSafeModeEnabled()).toBe(true);
});

it('defaults to false when loader cannot provide state', () => {
const baseState = store.getState();
vi.spyOn(store, 'getState').mockReturnValue({
...baseState,
emhttp: {
...baseState.emhttp,
var: {
...(baseState.emhttp?.var ?? {}),
safeMode: undefined as unknown as boolean,
} as typeof baseState.emhttp.var,
} as typeof baseState.emhttp,
} as typeof baseState);
vi.spyOn(stateFileLoader, 'loadStateFileSync').mockReturnValue(null);

expect(isSafeModeEnabled()).toBe(false);
});
});
17 changes: 17 additions & 0 deletions api/src/core/utils/safe-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { store } from '@app/store/index.js';
import { loadStateFileSync } from '@app/store/services/state-file-loader.js';
import { StateFileKey } from '@app/store/types.js';

export const isSafeModeEnabled = (): boolean => {
const safeModeFromStore = store.getState().emhttp?.var?.safeMode;
if (typeof safeModeFromStore === 'boolean') {
return safeModeFromStore;
}

const varState = loadStateFileSync(StateFileKey.var);
if (varState) {
return Boolean(varState.safeMode);
}

return false;
};
15 changes: 14 additions & 1 deletion api/src/store/modules/emhttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,18 @@ export const loadStateFiles = createAsyncThunk<
return state;
});

const stateFieldKeyMap: Record<StateFileKey, keyof SliceState> = {
[StateFileKey.var]: 'var',
[StateFileKey.devs]: 'devices',
[StateFileKey.network]: 'networks',
[StateFileKey.nginx]: 'nginx',
[StateFileKey.shares]: 'shares',
[StateFileKey.disks]: 'disks',
[StateFileKey.users]: 'users',
[StateFileKey.sec]: 'smbShares',
[StateFileKey.sec_nfs]: 'nfsShares',
};

export const emhttp = createSlice({
name: 'emhttp',
initialState,
Expand All @@ -175,7 +187,8 @@ export const emhttp = createSlice({
}>
) {
const { field } = action.payload;
return Object.assign(state, { [field]: action.payload.state });
const targetField = stateFieldKeyMap[field] ?? (field as keyof SliceState);
return Object.assign(state, { [targetField]: action.payload.state });
},
},
extraReducers(builder) {
Expand Down
81 changes: 81 additions & 0 deletions api/src/store/services/__test__/state-file-loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { store } from '@app/store/index.js';
import { loadStateFileSync } from '@app/store/services/state-file-loader.js';
import { StateFileKey } from '@app/store/types.js';

const VAR_FIXTURE = readFileSync(new URL('../../../../dev/states/var.ini', import.meta.url), 'utf-8');

const writeVarFixture = (dir: string, safeMode: 'yes' | 'no') => {
const content = VAR_FIXTURE.replace(/safeMode="(yes|no)"/, `safeMode="${safeMode}"`);
writeFileSync(join(dir, `${StateFileKey.var}.ini`), content);
};

describe('loadStateFileSync', () => {
let tempDir: string;
let baseState: ReturnType<typeof store.getState>;

beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'state-file-'));
baseState = store.getState();
});

afterEach(() => {
vi.restoreAllMocks();
rmSync(tempDir, { recursive: true, force: true });
});

it('loads var.ini, updates the store, and returns the parsed state', () => {
writeVarFixture(tempDir, 'yes');
vi.spyOn(store, 'getState').mockReturnValue({
...baseState,
paths: {
...baseState.paths,
states: tempDir,
},
});
const dispatchSpy = vi.spyOn(store, 'dispatch').mockImplementation((action) => action as any);

const result = loadStateFileSync(StateFileKey.var);

expect(result?.safeMode).toBe(true);
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'emhttp/updateEmhttpState',
payload: {
field: StateFileKey.var,
state: expect.objectContaining({ safeMode: true }),
},
})
);
});

it('returns null when the states path is missing', () => {
vi.spyOn(store, 'getState').mockReturnValue({
...baseState,
paths: undefined,
} as any);
const dispatchSpy = vi.spyOn(store, 'dispatch');

expect(loadStateFileSync(StateFileKey.var)).toBeNull();
expect(dispatchSpy).not.toHaveBeenCalled();
});

it('returns null when the requested state file cannot be found', () => {
vi.spyOn(store, 'getState').mockReturnValue({
...baseState,
paths: {
...baseState.paths,
states: tempDir,
},
});
const dispatchSpy = vi.spyOn(store, 'dispatch');

expect(loadStateFileSync(StateFileKey.var)).toBeNull();
expect(dispatchSpy).not.toHaveBeenCalled();
});
});
81 changes: 81 additions & 0 deletions api/src/store/services/state-file-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { join } from 'node:path';

import type { SliceState } from '@app/store/modules/emhttp.js';
import type { StateFileToIniParserMap } from '@app/store/types.js';
import { parseConfig } from '@app/core/utils/misc/parse-config.js';
import { store } from '@app/store/index.js';
import { updateEmhttpState } from '@app/store/modules/emhttp.js';
import { parse as parseDevices } from '@app/store/state-parsers/devices.js';
import { parse as parseNetwork } from '@app/store/state-parsers/network.js';
import { parse as parseNfs } from '@app/store/state-parsers/nfs.js';
import { parse as parseNginx } from '@app/store/state-parsers/nginx.js';
import { parse as parseShares } from '@app/store/state-parsers/shares.js';
import { parse as parseSlots } from '@app/store/state-parsers/slots.js';
import { parse as parseSmb } from '@app/store/state-parsers/smb.js';
import { parse as parseUsers } from '@app/store/state-parsers/users.js';
import { parse as parseVar } from '@app/store/state-parsers/var.js';
import { StateFileKey } from '@app/store/types.js';

type ParserReturnMap = {
[StateFileKey.var]: ReturnType<typeof parseVar>;
[StateFileKey.devs]: ReturnType<typeof parseDevices>;
[StateFileKey.network]: ReturnType<typeof parseNetwork>;
[StateFileKey.nginx]: ReturnType<typeof parseNginx>;
[StateFileKey.shares]: ReturnType<typeof parseShares>;
[StateFileKey.disks]: ReturnType<typeof parseSlots>;
[StateFileKey.users]: ReturnType<typeof parseUsers>;
[StateFileKey.sec]: ReturnType<typeof parseSmb>;
[StateFileKey.sec_nfs]: ReturnType<typeof parseNfs>;
};

const PARSER_MAP: { [K in StateFileKey]: StateFileToIniParserMap[K] } = {
[StateFileKey.var]: parseVar,
[StateFileKey.devs]: parseDevices,
[StateFileKey.network]: parseNetwork,
[StateFileKey.nginx]: parseNginx,
[StateFileKey.shares]: parseShares,
[StateFileKey.disks]: parseSlots,
[StateFileKey.users]: parseUsers,
[StateFileKey.sec]: parseSmb,
[StateFileKey.sec_nfs]: parseNfs,
};

/**
* Synchronously loads an emhttp state file, updates the Redux store slice, and returns the parsed state.
*
* Designed for bootstrap contexts (CLI, plugin loading, etc.) where dispatching the async thunks is
* impractical but we still need authoritative emhttp state from disk.
*/
export const loadStateFileSync = <K extends StateFileKey>(
stateFileKey: K
): ParserReturnMap[K] | null => {
const state = store.getState();
const statesDirectory = state.paths?.states;

if (!statesDirectory) {
return null;
}

const filePath = join(statesDirectory, `${stateFileKey}.ini`);

try {
const parser = PARSER_MAP[stateFileKey] as StateFileToIniParserMap[K];
const rawConfig = parseConfig<Record<string, unknown>>({
filePath,
type: 'ini',
});
const config = rawConfig as Parameters<StateFileToIniParserMap[K]>[0];
const parsed = (parser as (input: any) => ParserReturnMap[K])(config);

store.dispatch(
updateEmhttpState({
field: stateFileKey,
state: parsed as Partial<SliceState[keyof SliceState]>,
})
);

return parsed;
} catch (error) {
return null;
}
};
46 changes: 46 additions & 0 deletions api/src/unraid-api/plugin/__test__/plugin.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import * as safeModeUtils from '@app/core/utils/safe-mode.js';
import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';

type PluginServicePrivateApi = {
plugins?: Promise<unknown>;
importPlugins(): Promise<unknown>;
};

const PrivatePluginService = PluginService as unknown as PluginServicePrivateApi;

describe('PluginService.getPlugins safe mode handling', () => {
beforeEach(() => {
PrivatePluginService.plugins = undefined;
});

afterEach(() => {
PrivatePluginService.plugins = undefined;
vi.restoreAllMocks();
});

it('returns an empty array and skips imports when safe mode is enabled', async () => {
const safeModeSpy = vi.spyOn(safeModeUtils, 'isSafeModeEnabled').mockReturnValue(true);
const importSpy = vi
.spyOn(PrivatePluginService, 'importPlugins')
.mockResolvedValue([{ name: 'example', version: '1.0.0' }]);

const plugins = await PluginService.getPlugins();

expect(plugins).toEqual([]);
expect(safeModeSpy).toHaveBeenCalledTimes(1);
expect(importSpy).not.toHaveBeenCalled();
});

it('loads plugins when safe mode is disabled', async () => {
const expected = [{ name: 'example', version: '1.0.0' }];
vi.spyOn(safeModeUtils, 'isSafeModeEnabled').mockReturnValue(false);
const importSpy = vi.spyOn(PrivatePluginService, 'importPlugins').mockResolvedValue(expected);

const plugins = await PluginService.getPlugins();

expect(plugins).toBe(expected);
expect(importSpy).toHaveBeenCalledTimes(1);
});
});
12 changes: 11 additions & 1 deletion api/src/unraid-api/plugin/plugin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';

import type { ApiNestPluginDefinition } from '@app/unraid-api/plugin/plugin.interface.js';
import { pluginLogger } from '@app/core/log.js';
import { isSafeModeEnabled } from '@app/core/utils/safe-mode.js';
import { getPackageJson } from '@app/environment.js';
import { loadApiConfig } from '@app/unraid-api/config/api-config.module.js';
import { NotificationImportance } from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js';
Expand All @@ -20,7 +21,16 @@ export class PluginService {
private static plugins: Promise<Plugin[]> | undefined;

static async getPlugins() {
PluginService.plugins ??= PluginService.importPlugins();
if (!PluginService.plugins) {
if (isSafeModeEnabled()) {
PluginService.logger.warn(
'Safe mode enabled (vars.ini); skipping API plugin discovery and load.'
);
PluginService.plugins = Promise.resolve([]);
} else {
PluginService.plugins = PluginService.importPlugins();
}
}
return PluginService.plugins;
}

Expand Down
Loading