homeio
PublicForked from doctor-io/homeio
network-storage.test.ts
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let mockDataRoot = "";
vi.mock("@/lib/server/modules/files/path-resolver", () => {
class FilesPathError extends Error {
code: string;
statusCode: number;
constructor(
message: string,
options?: {
code?: string;
statusCode?: number;
},
) {
super(message);
this.code = options?.code ?? "internal_error";
this.statusCode = options?.statusCode ?? 500;
}
}
function ensureWithinRoot(rootPath: string, absolutePath: string) {
const relative = path.relative(rootPath, absolutePath);
const within =
relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
if (!within) {
throw new FilesPathError("Path escapes root", {
code: "path_outside_root",
statusCode: 400,
});
}
}
return {
FilesPathError,
resolvePathWithinFilesRoot: vi.fn(async (input: {
inputPath?: string;
requiredPrefix?: string;
allowMissingLeaf?: boolean;
}) => {
const cleaned = (input.inputPath ?? "").trim().replaceAll("\\", "/");
const normalized = cleaned ? path.posix.normalize(cleaned) : "";
if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) {
throw new FilesPathError("Invalid path", {
code: "invalid_path",
statusCode: 400,
});
}
if (
input.requiredPrefix &&
normalized !== input.requiredPrefix &&
!normalized.startsWith(`${input.requiredPrefix}/`)
) {
throw new FilesPathError("Invalid path", {
code: "invalid_path",
statusCode: 400,
});
}
const absolutePath = path.resolve(mockDataRoot, normalized);
ensureWithinRoot(mockDataRoot, absolutePath);
return {
rootPath: mockDataRoot,
relativePath: normalized,
absolutePath,
segments: normalized.split("/"),
exists: true,
};
}),
};
});
vi.mock("@/lib/server/modules/files/network-shares-repository", () => ({
listNetworkSharesFromDb: vi.fn(),
getNetworkShareFromDb: vi.fn(),
getNetworkShareByMountPathFromDb: vi.fn(),
insertNetworkShareInDb: vi.fn(),
touchNetworkShareInDb: vi.fn(),
deleteNetworkShareFromDb: vi.fn(),
}));
vi.mock("node:child_process", () => ({
execFile: vi.fn(),
}));
import { execFile } from "node:child_process";
import {
addShare,
discoverServers,
discoverShares,
getShareInfo,
mountShare,
removeShare,
type NetworkStorageError,
} from "@/lib/server/modules/files/network-storage";
import {
deleteNetworkShareFromDb,
getNetworkShareByMountPathFromDb,
getNetworkShareFromDb,
insertNetworkShareInDb,
listNetworkSharesFromDb,
touchNetworkShareInDb,
} from "@/lib/server/modules/files/network-shares-repository";
import { resolvePathWithinFilesRoot } from "@/lib/server/modules/files/path-resolver";
import { encryptSecret } from "@/lib/server/modules/files/secrets";
type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void;
function getExecFileCallback(restArgs: unknown[]) {
const callback = restArgs.at(-1);
if (typeof callback !== "function") {
throw new Error("Missing execFile callback");
}
return callback as ExecFileCallback;
}
describe("network storage service", () => {
beforeEach(async () => {
mockDataRoot = await mkdtemp(path.join(os.tmpdir(), "home-server-network-"));
await mkdir(path.join(mockDataRoot, "Network"), { recursive: true });
vi.clearAllMocks();
});
afterEach(async () => {
if (mockDataRoot) {
await rm(mockDataRoot, { recursive: true, force: true });
}
});
it("rejects duplicate share mount paths", async () => {
vi.mocked(getNetworkShareByMountPathFromDb).mockResolvedValueOnce({
id: "existing",
host: "nas.local",
share: "Media",
username: "user",
mountPath: "Network/nas.local/Media",
passwordCiphertext: "x",
passwordIv: "x",
passwordTag: "x",
createdAt: new Date(),
updatedAt: new Date(),
});
await expect(
addShare({
host: "nas.local",
share: "Media",
username: "user",
password: "secret",
}),
).rejects.toMatchObject<Partial<NetworkStorageError>>({
code: "share_exists",
statusCode: 409,
});
});
it("parses discovered SMB servers", async () => {
vi.mocked(execFile).mockImplementation(
(command: string, ...restArgs: unknown[]) => {
const callback = getExecFileCallback(restArgs);
if (command === "avahi-browse") {
callback(
null,
`=;eth0;IPv4;NAS;_smb._tcp;local;nas.local;192.168.1.5;445\n=;eth0;IPv4;NAS;_smb._tcp;local;nas.local;192.168.1.5;445`,
"",
);
return {} as never;
}
callback(new Error(`Unexpected command ${command}`), "", "");
return {} as never;
},
);
const result = await discoverServers();
expect(result.servers).toEqual(["nas.local"]);
});
it("returns empty servers when avahi-browse is unavailable", async () => {
vi.mocked(execFile).mockImplementation(
(command: string, ...restArgs: unknown[]) => {
const callback = getExecFileCallback(restArgs);
if (command === "avahi-browse") {
const error = new Error("spawn avahi-browse ENOENT") as Error & {
code?: string;
};
error.code = "ENOENT";
callback(error, "", "");
return {} as never;
}
callback(new Error(`Unexpected command ${command}`), "", "");
return {} as never;
},
);
const result = await discoverServers();
expect(result.servers).toEqual([]);
});
it("parses discovered SMB shares", async () => {
vi.mocked(execFile).mockImplementation(
(command: string, ...restArgs: unknown[]) => {
const callback = getExecFileCallback(restArgs);
if (command === "smbclient") {
callback(
null,
`Disk|Media|Network Share\nDisk|Backups|Network Share\nDisk|IPC$|IPC`,
"",
);
return {} as never;
}
callback(new Error(`Unexpected command ${command}`), "", "");
return {} as never;
},
);
const result = await discoverShares({
host: "nas.local",
username: "user",
password: "secret",
});
expect(result.shares).toEqual(["Backups", "Media"]);
});
it("returns empty shares when smbclient is unavailable", async () => {
vi.mocked(execFile).mockImplementation(
(command: string, ...restArgs: unknown[]) => {
const callback = getExecFileCallback(restArgs);
if (command === "smbclient") {
const error = new Error("spawn smbclient ENOENT") as Error & {
code?: string;
};
error.code = "ENOENT";
callback(error, "", "");
return {} as never;
}
callback(new Error(`Unexpected command ${command}`), "", "");
return {} as never;
},
);
const result = await discoverShares({
host: "nas.local",
username: "user",
password: "secret",
});
expect(result.shares).toEqual([]);
});
it("mounts an existing share", async () => {
const encrypted = encryptSecret("secret");
vi.mocked(getNetworkShareFromDb).mockResolvedValueOnce({
id: "share-1",
host: "nas.local",
share: "Media",
username: "user",
mountPath: "Network/nas.local/Media",
passwordCiphertext: encrypted.ciphertext,
passwordIv: encrypted.iv,
passwordTag: encrypted.tag,
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(touchNetworkShareInDb).mockResolvedValueOnce({
id: "share-1",
host: "nas.local",
share: "Media",
username: "user",
mountPath: "Network/nas.local/Media",
passwordCiphertext: encrypted.ciphertext,
passwordIv: encrypted.iv,
passwordTag: encrypted.tag,
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(execFile).mockImplementation(
(command: string, ...restArgs: unknown[]) => {
const callback = getExecFileCallback(restArgs);
if (command === "mountpoint") {
const error = new Error("not mounted") as Error & { code?: number };
error.code = 1;
callback(error, "", "");
return {} as never;
}
if (command === "mount") {
callback(null, "", "");
return {} as never;
}
callback(new Error(`Unexpected command ${command}`), "", "");
return {} as never;
},
);
const mounted = await mountShare("share-1");
expect(mounted.isMounted).toBe(true);
expect(vi.mocked(insertNetworkShareInDb)).not.toHaveBeenCalled();
});
it("rolls back db reservation when mount fails", async () => {
vi.mocked(getNetworkShareByMountPathFromDb).mockResolvedValueOnce(null);
vi.mocked(insertNetworkShareInDb).mockResolvedValueOnce({
id: "share-rollback",
host: "nas.local",
share: "Media",
username: "user",
mountPath: "Network/nas.local/Media",
passwordCiphertext: "x",
passwordIv: "x",
passwordTag: "x",
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(deleteNetworkShareFromDb).mockResolvedValueOnce({
id: "share-rollback",
host: "nas.local",
share: "Media",
username: "user",
mountPath: "Network/nas.local/Media",
passwordCiphertext: "x",
passwordIv: "x",
passwordTag: "x",
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(execFile).mockImplementation((command: string, ...restArgs: unknown[]) => {
const callback = getExecFileCallback(restArgs);
if (command === "mountpoint") {
const error = new Error("not mounted") as Error & { code?: number };
error.code = 1;
callback(error, "", "");
return {} as never;
}
if (command === "mount") {
const error = new Error("permission denied") as Error & { code?: number };
error.code = 1;
callback(error, "", "permission denied");
return {} as never;
}
callback(null, "", "");
return {} as never;
});
await expect(
addShare({
host: "nas.local",
share: "Media",
username: "user",
password: "secret",
}),
).rejects.toMatchObject<Partial<NetworkStorageError>>({
code: "mount_failed",
statusCode: 500,
});
expect(deleteNetworkShareFromDb).toHaveBeenCalledWith("share-rollback");
});
it("returns shares even when mount status resolution fails", async () => {
vi.mocked(listNetworkSharesFromDb).mockResolvedValueOnce([
{
id: "share-1",
host: "nas.local",
share: "Media",
username: "user",
mountPath: "Network/nas.local/Media",
passwordCiphertext: "x",
passwordIv: "x",
passwordTag: "x",
createdAt: new Date(),
updatedAt: new Date(),
},
]);
vi.mocked(resolvePathWithinFilesRoot).mockRejectedValueOnce(
new Error("resolver failed"),
);
const shares = await getShareInfo();
expect(shares).toHaveLength(1);
expect(shares[0]).toMatchObject({
id: "share-1",
isMounted: false,
});
});
it("removes share when umount reports already unmounted", async () => {
const encrypted = encryptSecret("secret");
vi.mocked(getNetworkShareFromDb).mockResolvedValueOnce({
id: "share-remove",
host: "nas.local",
share: "Media",
username: "user",
mountPath: "Network/nas.local/Media",
passwordCiphertext: encrypted.ciphertext,
passwordIv: encrypted.iv,
passwordTag: encrypted.tag,
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(deleteNetworkShareFromDb).mockResolvedValueOnce({
id: "share-remove",
host: "nas.local",
share: "Media",
username: "user",
mountPath: "Network/nas.local/Media",
passwordCiphertext: encrypted.ciphertext,
passwordIv: encrypted.iv,
passwordTag: encrypted.tag,
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(execFile).mockImplementation((command: string, ...restArgs: unknown[]) => {
const callback = getExecFileCallback(restArgs);
if (command === "mountpoint") {
callback(null, "", "");
return {} as never;
}
if (command === "umount") {
const error = new Error("not mounted") as Error & { code?: number; stderr?: string };
error.code = 1;
error.stderr = "not mounted";
callback(error, "", "not mounted");
return {} as never;
}
callback(null, "", "");
return {} as never;
});
const result = await removeShare("share-remove");
expect(result).toEqual({
removed: true,
id: "share-remove",
});
expect(deleteNetworkShareFromDb).toHaveBeenCalledWith("share-remove");
});
it("removes stale db record when mount path is no longer valid", async () => {
const encrypted = encryptSecret("secret");
vi.mocked(getNetworkShareFromDb).mockResolvedValueOnce({
id: "share-stale",
host: "nas.local",
share: "Media",
username: "user",
mountPath: "Shared/nas.local/Media",
passwordCiphertext: encrypted.ciphertext,
passwordIv: encrypted.iv,
passwordTag: encrypted.tag,
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(deleteNetworkShareFromDb).mockResolvedValueOnce({
id: "share-stale",
host: "nas.local",
share: "Media",
username: "user",
mountPath: "Shared/nas.local/Media",
passwordCiphertext: encrypted.ciphertext,
passwordIv: encrypted.iv,
passwordTag: encrypted.tag,
createdAt: new Date(),
updatedAt: new Date(),
});
const result = await removeShare("share-stale");
expect(result).toEqual({
removed: true,
id: "share-stale",
});
expect(deleteNetworkShareFromDb).toHaveBeenCalledWith("share-stale");
});
});