new era
This commit is contained in:
1 parent
e30f89697b
commit
3e24d4a7bb
26 files changed
+4224
-4
No files matched your search
@@ -0,0 +1,310 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
vi.mock("../src/server/env", () => ({
|
||||
env: {
|
||||
mcDataDir: "",
|
||||
appDataDir: "",
|
||||
projectDir: "",
|
||||
managed: false,
|
||||
rconPassword: "test-rcon",
|
||||
restoreHelper: "/helper.py",
|
||||
containerRuntime: "docker",
|
||||
},
|
||||
}));
|
||||
vi.mock("../src/server/docker", () => ({
|
||||
inspectContainer: vi.fn(async () => ({
|
||||
exists: true,
|
||||
running: true,
|
||||
health: "healthy",
|
||||
})),
|
||||
powerContainer: vi.fn(async () => {}),
|
||||
execInContainer: vi.fn(),
|
||||
networkInfrastructure: vi.fn(async () => ({
|
||||
hostDataDir: "/srv/data/mc",
|
||||
network: "stack",
|
||||
})),
|
||||
}));
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: vi.fn(),
|
||||
execFile: vi.fn((...args: unknown[]) => {
|
||||
const cb = args.at(-1) as (
|
||||
err: Error | null,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
) => void;
|
||||
cb(null, "", "");
|
||||
}),
|
||||
}));
|
||||
vi.mock("../src/server/apple-container", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../src/server/apple-container")>()),
|
||||
appleRefreshBackup: vi.fn(async () => {}),
|
||||
appleWaitReady: vi.fn(async () => {}),
|
||||
appleAddress: vi.fn(async () => "192.0.2.25"),
|
||||
}));
|
||||
import { env } from "../src/server/env";
|
||||
import { execFile } from "node:child_process";
|
||||
import { inspectContainer, powerContainer } from "../src/server/docker";
|
||||
import { defaultProfile } from "../src/lib/network";
|
||||
import {
|
||||
readNetwork,
|
||||
writeNetwork,
|
||||
validateProfile,
|
||||
profileRoot,
|
||||
} from "../src/server/network-config";
|
||||
import {
|
||||
deployProfile,
|
||||
snapshotProfile,
|
||||
restoreProfile,
|
||||
restartProfile,
|
||||
stopProfile,
|
||||
queueNetworkJob,
|
||||
reconcileJobs,
|
||||
networkBusy,
|
||||
assertNetworkIdle,
|
||||
} from "../src/server/network";
|
||||
import {
|
||||
appleRunArguments,
|
||||
appleRefreshBackup,
|
||||
appleWaitReady,
|
||||
} from "../src/server/apple-container";
|
||||
let root: string;
|
||||
let id: string;
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "craftdeck-ops-"));
|
||||
env.mcDataDir = path.join(root, "mc");
|
||||
env.appDataDir = path.join(root, "app");
|
||||
env.projectDir = root;
|
||||
env.managed = false;
|
||||
env.containerRuntime = "docker";
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(inspectContainer).mockResolvedValue({
|
||||
exists: true,
|
||||
running: true,
|
||||
health: "healthy",
|
||||
status: "running",
|
||||
});
|
||||
const p = validateProfile({ ...defaultProfile, name: "Lobby" });
|
||||
id = p.id;
|
||||
writeNetwork({
|
||||
schema: 1,
|
||||
profiles: [p],
|
||||
proxy: { port: 25566, lobby: id, motd: "Network", version: "latest" },
|
||||
jobs: [],
|
||||
});
|
||||
fs.mkdirSync(path.join(profileRoot(id), "data", "world"), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(profileRoot(id), "data", "world", "level.dat"),
|
||||
"test",
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
describe("network lifecycle safety", () => {
|
||||
it("reconnects native backups and gateway routes after a restart changes the world IP", async () => {
|
||||
env.containerRuntime = "apple";
|
||||
await restartProfile(id);
|
||||
expect(powerContainer).toHaveBeenCalledWith(
|
||||
`craftdeck-${id}-backup`,
|
||||
"stop",
|
||||
);
|
||||
expect(appleRefreshBackup).toHaveBeenCalledWith(
|
||||
path.join(profileRoot(id), "compose.json"),
|
||||
);
|
||||
expect(
|
||||
fs.readFileSync(
|
||||
path.join(profileRoot("proxy"), "config", "velocity.toml"),
|
||||
"utf8",
|
||||
),
|
||||
).toContain("192.0.2.25:25565");
|
||||
expect(powerContainer).toHaveBeenCalledWith(
|
||||
"craftdeck-velocity",
|
||||
"restart",
|
||||
);
|
||||
expect(appleWaitReady).toHaveBeenCalledWith("craftdeck-velocity", 180);
|
||||
const backupCall =
|
||||
vi.mocked(appleRefreshBackup).mock.invocationCallOrder[0];
|
||||
const gatewayCall = vi
|
||||
.mocked(powerContainer)
|
||||
.mock.invocationCallOrder.at(-1)!;
|
||||
expect(backupCall).toBeLessThan(gatewayCall);
|
||||
});
|
||||
it("restores native connections even when snapshot creation fails", async () => {
|
||||
env.containerRuntime = "apple";
|
||||
vi.mocked(execFile).mockImplementationOnce((...args: unknown[]) => {
|
||||
(args.at(-1) as (e: Error) => void)(new Error("disk full"));
|
||||
return {} as ReturnType<typeof execFile>;
|
||||
});
|
||||
await expect(snapshotProfile(id, () => {})).rejects.toThrow("disk full");
|
||||
expect(appleRefreshBackup).toHaveBeenCalledWith(
|
||||
path.join(profileRoot(id), "compose.json"),
|
||||
);
|
||||
expect(powerContainer).toHaveBeenCalledWith(
|
||||
"craftdeck-velocity",
|
||||
"restart",
|
||||
);
|
||||
});
|
||||
it("removes a stopped native world from routes instead of retaining its reusable IP", async () => {
|
||||
env.containerRuntime = "apple";
|
||||
const config = readNetwork();
|
||||
const other = validateProfile({ ...defaultProfile, name: "Other world" });
|
||||
config.profiles.push(other);
|
||||
writeNetwork(config);
|
||||
vi.mocked(inspectContainer).mockImplementation(async (name) => ({
|
||||
exists: true,
|
||||
running: name !== `craftdeck-${id}`,
|
||||
status: "running",
|
||||
health: "healthy",
|
||||
}));
|
||||
await stopProfile(id);
|
||||
const routes = fs.readFileSync(
|
||||
path.join(profileRoot("proxy"), "config", "velocity.toml"),
|
||||
"utf8",
|
||||
);
|
||||
expect(routes).not.toContain(id);
|
||||
expect(routes).toContain(other.id);
|
||||
expect(readNetwork().proxy.lobby).toBe(id);
|
||||
});
|
||||
it("stops the native gateway when its last world is stopped", async () => {
|
||||
env.containerRuntime = "apple";
|
||||
vi.mocked(inspectContainer).mockImplementation(async (name) => ({
|
||||
exists: true,
|
||||
running: name !== `craftdeck-${id}`,
|
||||
status: "running",
|
||||
health: "healthy",
|
||||
}));
|
||||
expect(await stopProfile(id)).toContain("Apply routing");
|
||||
expect(powerContainer).toHaveBeenCalledWith("craftdeck-velocity", "stop");
|
||||
expect(powerContainer).not.toHaveBeenCalledWith(
|
||||
"craftdeck-velocity",
|
||||
"restart",
|
||||
);
|
||||
});
|
||||
it("backup failure cancels deployment and resumes original writers", async () => {
|
||||
vi.mocked(execFile).mockImplementationOnce((...args: unknown[]) => {
|
||||
(args.at(-1) as (e: Error) => void)(new Error("disk full"));
|
||||
return {} as ReturnType<typeof execFile>;
|
||||
});
|
||||
await expect(deployProfile(id, () => {})).rejects.toThrow("disk full");
|
||||
expect(vi.mocked(execFile).mock.calls.some((c) => c[0] === "docker")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(powerContainer).toHaveBeenCalledWith(`craftdeck-${id}`, "start");
|
||||
expect(powerContainer).toHaveBeenCalledWith(
|
||||
`craftdeck-${id}-backup`,
|
||||
"start",
|
||||
);
|
||||
expect(fs.existsSync(path.join(profileRoot(id), "compose.json"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
it("does not stop or change a world when map compatibility resolution fails", async () => {
|
||||
const c = readNetwork();
|
||||
c.profiles[0].map = "bluemap";
|
||||
writeNetwork(c);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: true, json: async () => [] })),
|
||||
);
|
||||
await expect(deployProfile(id, () => {})).rejects.toThrow("No stable");
|
||||
expect(powerContainer).not.toHaveBeenCalled();
|
||||
expect(execFile).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not publish an archive until verification succeeds", async () => {
|
||||
vi.mocked(execFile).mockImplementationOnce((...args: unknown[]) => {
|
||||
const argv = args[1] as string[];
|
||||
fs.writeFileSync(argv[1], "partial");
|
||||
(args.at(-1) as (e: null, stdout: string, stderr: string) => void)(
|
||||
null,
|
||||
"",
|
||||
"",
|
||||
);
|
||||
return {} as ReturnType<typeof execFile>;
|
||||
});
|
||||
vi.mocked(execFile).mockImplementationOnce((...args: unknown[]) => {
|
||||
(args.at(-1) as (e: Error) => void)(new Error("archive invalid"));
|
||||
return {} as ReturnType<typeof execFile>;
|
||||
});
|
||||
await expect(snapshotProfile(id, () => {})).rejects.toThrow(
|
||||
"archive invalid",
|
||||
);
|
||||
expect(
|
||||
fs
|
||||
.readdirSync(path.join(profileRoot(id), "backups"))
|
||||
.every((n) => n.endsWith(".partial")),
|
||||
).toBe(true);
|
||||
});
|
||||
it("validates restore selection before stopping writers", async () => {
|
||||
await expect(restoreProfile(id, "../../bad.tgz", () => {})).rejects.toThrow(
|
||||
"Backup not found",
|
||||
);
|
||||
expect(powerContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
it("marks orphan jobs failed without retrying destructive operations", () => {
|
||||
const c = readNetwork();
|
||||
c.jobs = [
|
||||
{
|
||||
id: "old",
|
||||
profileId: id,
|
||||
action: "restore",
|
||||
state: "running",
|
||||
message: "",
|
||||
startedAt: 1,
|
||||
},
|
||||
];
|
||||
writeNetwork(c);
|
||||
expect(reconcileJobs(c).jobs[0].state).toBe("failed");
|
||||
expect(powerContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
it("blocks conflicting edits and persists job failures without secrets", async () => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const job = queueNetworkJob(id, "test", async () => {
|
||||
await gate;
|
||||
throw new Error(`oops ${env.rconPassword}`);
|
||||
});
|
||||
expect(networkBusy()).toBe(true);
|
||||
expect(() => assertNetworkIdle()).toThrow("Wait");
|
||||
expect(() => queueNetworkJob(id, "second", async () => "ok")).toThrow(
|
||||
"Wait",
|
||||
);
|
||||
release();
|
||||
await vi.waitFor(() => expect(networkBusy()).toBe(false));
|
||||
const saved = readNetwork().jobs.find((j) => j.id === job.id)!;
|
||||
expect(saved.state).toBe("failed");
|
||||
expect(saved.message).toBe("oops [redacted]");
|
||||
});
|
||||
it("managed deployments reject jobs before changing persistent state", () => {
|
||||
env.managed = true;
|
||||
expect(() => queueNetworkJob(id, "start", async () => "ok")).toThrow(
|
||||
"managed by Komodo",
|
||||
);
|
||||
expect(readNetwork().jobs).toEqual([]);
|
||||
});
|
||||
it("uses the native Apple CLI with separate arguments and a credential file", () => {
|
||||
const args = appleRunArguments(
|
||||
{
|
||||
image: "itzg/minecraft-server:java21",
|
||||
container_name: "craftdeck-lobby",
|
||||
environment: { RCON_PASSWORD: "never-in-argv" },
|
||||
mem_limit: "3g",
|
||||
volumes: ["/path with spaces/data:/data"],
|
||||
ports: ["127.0.0.1:8101:8100"],
|
||||
},
|
||||
"craftdeck",
|
||||
"/private/profile.env",
|
||||
);
|
||||
expect(args).toContain("/path with spaces/data:/data");
|
||||
expect(args).toContain("3G");
|
||||
expect(args).not.toContain("never-in-argv");
|
||||
expect(args).not.toContain("docker");
|
||||
expect(args).toContain("--env-file");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
vi.mock("../src/server/env", () => ({
|
||||
env: { mcDataDir: "", appDataDir: "" },
|
||||
}));
|
||||
import { env } from "../src/server/env";
|
||||
import {
|
||||
defaultProfile,
|
||||
type NetworkConfig,
|
||||
type WorldProfile,
|
||||
} from "../src/lib/network";
|
||||
import {
|
||||
validateProfile,
|
||||
validatePorts,
|
||||
validateProxy,
|
||||
profileRoot,
|
||||
readNetwork,
|
||||
writeNetwork,
|
||||
profileById,
|
||||
} from "../src/server/network-config";
|
||||
import {
|
||||
composeJson,
|
||||
renderProfileCompose,
|
||||
renderVelocity,
|
||||
serverEnvironment,
|
||||
} from "../src/server/network-render";
|
||||
import { networkFilePath } from "../src/server/network-files";
|
||||
let dir: string;
|
||||
const make = (overrides: Partial<WorldProfile> = {}) =>
|
||||
validateProfile({ ...defaultProfile, name: "Test world", ...overrides });
|
||||
const config = (profiles: WorldProfile[]): NetworkConfig => ({
|
||||
schema: 1,
|
||||
profiles,
|
||||
proxy: {
|
||||
port: 25566,
|
||||
lobby: profiles[0]?.id ?? "",
|
||||
motd: 'Friends "together"',
|
||||
version: "latest",
|
||||
},
|
||||
jobs: [],
|
||||
});
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "craftdeck-network-"));
|
||||
env.mcDataDir = path.join(dir, "mc");
|
||||
env.appDataDir = path.join(dir, "app");
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
describe("isolated server profiles", () => {
|
||||
it("generates server-owned IDs and ignores caller supplied paths", () => {
|
||||
const p = make({ id: "../../outside", name: "../Friends ♥", createdAt: 1 });
|
||||
expect(p.id).toMatch(/^friends-[a-f0-9]{8}$/);
|
||||
expect(p.createdAt).not.toBe(1);
|
||||
expect(() => profileRoot("../escape")).toThrow();
|
||||
});
|
||||
it.each(["FABRIC", "FORGE", "NEOFORGE", "QUILT", "VANILLA", "MODRINTH"])(
|
||||
"does not claim transparent proxy support for %s",
|
||||
(loader) => {
|
||||
expect(() =>
|
||||
make({ loader: loader as WorldProfile["loader"], route: "proxy" }),
|
||||
).toThrow("Automatic secure proxy");
|
||||
},
|
||||
);
|
||||
it("requires exact modpack selection", () => {
|
||||
expect(() =>
|
||||
make({ loader: "MODRINTH", route: "direct", modpack: "pack" }),
|
||||
).toThrow("exact pack version");
|
||||
const p = make({
|
||||
loader: "MODRINTH",
|
||||
route: "direct",
|
||||
modpack: "pack",
|
||||
modpackVersion: "version123",
|
||||
});
|
||||
expect(serverEnvironment(p, "secret")).toMatchObject({
|
||||
TYPE: "MODRINTH",
|
||||
MODRINTH_MODPACK: "pack",
|
||||
MODRINTH_VERSION: "version123",
|
||||
});
|
||||
});
|
||||
it("rejects invalid numbers, types, environment newlines and URL schemes", () => {
|
||||
for (const update of [
|
||||
{ memory: 0 },
|
||||
{ memory: "4" },
|
||||
{ pvp: "true" },
|
||||
{ name: "world\nEULA=TRUE" },
|
||||
{ resourcePack: "javascript:alert(1)" },
|
||||
{ mapUrl: "https://user:password@example.com" },
|
||||
{ version: "latest" },
|
||||
])
|
||||
expect(() =>
|
||||
validateProfile({ ...defaultProfile, name: "world", ...update }),
|
||||
).toThrow();
|
||||
});
|
||||
it("requires checksums and validates resource pack consistency", () => {
|
||||
expect(() =>
|
||||
make({ resourcePack: "https://example.com/pack.zip" }),
|
||||
).toThrow("SHA-1");
|
||||
expect(() => make({ resourcePackRequired: true })).toThrow("download URL");
|
||||
const p = make({
|
||||
resourcePack: "https://example.com/pack.zip",
|
||||
resourcePackSha1: "a".repeat(40),
|
||||
resourcePackRequired: true,
|
||||
});
|
||||
expect(serverEnvironment(p, "secret")).toMatchObject({
|
||||
RESOURCE_PACK_ENFORCE: "true",
|
||||
RESOURCE_PACK_SHA1: "a".repeat(40),
|
||||
});
|
||||
});
|
||||
it("prevents seed, identity, version and runtime changes after initialization", () => {
|
||||
const p = make();
|
||||
fs.mkdirSync(path.join(profileRoot(p.id), "data"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(profileRoot(p.id), "data", "server.properties"),
|
||||
"",
|
||||
);
|
||||
for (const update of [
|
||||
{ seed: "123" },
|
||||
{ version: "1.21.4" },
|
||||
{ route: "direct" },
|
||||
{ loader: "FOLIA" },
|
||||
])
|
||||
expect(() => validateProfile({ ...p, ...update }, p)).toThrow(
|
||||
"Create a new profile",
|
||||
);
|
||||
expect(validateProfile({ ...p, memory: 8 }, p).memory).toBe(8);
|
||||
});
|
||||
it("persists registry and rejects malformed registry instead of losing profiles", () => {
|
||||
const p = make();
|
||||
writeNetwork(config([p]));
|
||||
expect(readNetwork().profiles[0]).toEqual(p);
|
||||
expect(profileById(readNetwork(), p.id)).toEqual(p);
|
||||
fs.writeFileSync(path.join(env.appDataDir, "network.json"), "{");
|
||||
expect(() => readNetwork()).toThrow();
|
||||
});
|
||||
});
|
||||
describe("routing and network isolation", () => {
|
||||
it("reserves gateway, primary, dashboard, direct, and map ports together", () => {
|
||||
for (const port of [25565, 25566, 8080])
|
||||
expect(() =>
|
||||
validatePorts(config([make({ route: "direct", port })]), 25565, 8080),
|
||||
).toThrow("already assigned");
|
||||
expect(() =>
|
||||
validatePorts(
|
||||
config([
|
||||
make({
|
||||
route: "direct",
|
||||
port: 25570,
|
||||
map: "bluemap",
|
||||
mapPort: 25570,
|
||||
}),
|
||||
]),
|
||||
25565,
|
||||
8080,
|
||||
),
|
||||
).toThrow("already assigned");
|
||||
});
|
||||
it("allows private backends to share internal game ports", () => {
|
||||
expect(() =>
|
||||
validatePorts(config([make(), make({ name: "Survival" })]), 25565, 8080),
|
||||
).not.toThrow();
|
||||
});
|
||||
it("rejects duplicate hostname routes and direct-server lobbies", () => {
|
||||
const p = make({ hostname: "survival.example.com" });
|
||||
expect(() =>
|
||||
validatePorts(config([p, make({ hostname: p.hostname })]), 25565, 8080),
|
||||
).toThrow("Duplicate hostname");
|
||||
const direct = make({ route: "direct" });
|
||||
const c = config([direct]);
|
||||
expect(() => validateProxy(c.proxy, c)).toThrow("Paper/Folia");
|
||||
});
|
||||
it("publishes no backend or RCON ports, isolates storage and enables forwarding authentication", () => {
|
||||
const p = make({ map: "bluemap" });
|
||||
const rendered = renderProfileCompose(
|
||||
p,
|
||||
{ hostDataDir: "/srv/game/data/mc", network: "minecraft_default" },
|
||||
"secret",
|
||||
"bluemap:exact",
|
||||
);
|
||||
expect(rendered.services.server.ports).toEqual(["127.0.0.1:8101:8100"]);
|
||||
expect(rendered.services.server.volumes[0]).toBe(
|
||||
`/srv/game/data/network/${p.id}/data:/data`,
|
||||
);
|
||||
expect(rendered.services.backup.volumes[1]).toBe(
|
||||
`/srv/game/data/network/${p.id}/backups:/backups`,
|
||||
);
|
||||
expect(rendered.services.server.environment.ONLINE_MODE).toBe("FALSE");
|
||||
expect(rendered.services.server.environment.MODRINTH_PROJECTS).toBe(
|
||||
"bluemap:exact",
|
||||
);
|
||||
});
|
||||
it("direct profiles verify accounts and never inherit primary Fabric mods", () => {
|
||||
const p = make({ loader: "FORGE", route: "direct" });
|
||||
const e = serverEnvironment(p, "secret");
|
||||
expect(e.ONLINE_MODE).toBe("TRUE");
|
||||
expect(e.MODRINTH_PROJECTS).toBe("");
|
||||
expect(e).not.toHaveProperty("FABRIC_LOADER_VERSION");
|
||||
});
|
||||
it("escapes Compose interpolation in all user strings and credentials", () => {
|
||||
expect(
|
||||
JSON.parse(composeJson({ password: "${SECRET}", motd: "Costs $5" })),
|
||||
).toEqual({ password: "$${SECRET}", motd: "Costs $$5" });
|
||||
});
|
||||
it("emits only compatible routes with explicit lobby and modern forwarding", () => {
|
||||
const lobby = make({ hostname: "lobby.example.com" });
|
||||
const direct = make({ loader: "FABRIC", route: "direct" });
|
||||
const text = renderVelocity(config([lobby, direct]));
|
||||
expect(text).toContain('player-info-forwarding-mode = "modern"');
|
||||
expect(text).toContain("online-mode = true");
|
||||
expect(text).toContain(`try = ["${lobby.id}"]`);
|
||||
expect(text).toContain(`"lobby.example.com" = ["${lobby.id}"]`);
|
||||
expect(text).not.toContain(direct.id);
|
||||
expect(text).toContain('motd = "Friends \\"together\\""');
|
||||
});
|
||||
});
|
||||
describe("profile file boundaries", () => {
|
||||
it("rejects traversal and symlink escapes including nonexistent descendants", () => {
|
||||
const p = make();
|
||||
fs.mkdirSync(path.join(profileRoot(p.id), "data"), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, "outside"));
|
||||
fs.symlinkSync(
|
||||
path.join(dir, "outside"),
|
||||
path.join(profileRoot(p.id), "data", "link"),
|
||||
);
|
||||
expect(() => networkFilePath(p.id, "../../other")).toThrow("escapes");
|
||||
expect(() => networkFilePath(p.id, "link/new/nested.yml")).toThrow(
|
||||
"escapes",
|
||||
);
|
||||
fs.symlinkSync(
|
||||
path.join(dir, "outside", "missing"),
|
||||
path.join(profileRoot(p.id), "data", "dangling"),
|
||||
);
|
||||
expect(() => networkFilePath(p.id, "dangling")).toThrow();
|
||||
expect(networkFilePath(p.id, "config/new.yml")).toBe(
|
||||
path.join(profileRoot(p.id), "data", "config", "new.yml"),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user