Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
649 lines
23 KiB
TypeScript
649 lines
23 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
COMPOSE_MANUAL_UPGRADE_COMMANDS,
|
|
chooseUpdateStrategy,
|
|
commitImageTag,
|
|
compareReleaseTags,
|
|
composePsArgv,
|
|
composePullArgv,
|
|
composeUpArgv,
|
|
composeUpdatePlan,
|
|
DEFAULT_COMPOSE_PROJECT_NAME,
|
|
DEFAULT_IMAGE_TAG,
|
|
forkImageTag,
|
|
gitIndexContentDiffArgv,
|
|
gitStatusArgv,
|
|
gitUntrackedFilesArgv,
|
|
gitWorktreeContentDiffArgv,
|
|
imageRef,
|
|
isValidComposeProjectName,
|
|
isValidImageName,
|
|
isValidImageTag,
|
|
manualUpgradeCommands,
|
|
OFFICIAL_SERVER_IMAGE,
|
|
parseGitNameOnly,
|
|
parseLsRemoteReleases,
|
|
parseLsRemoteTags,
|
|
parseReleaseTag,
|
|
RECREATED_SERVICES,
|
|
resolveComposeProjectName,
|
|
resolveExecutionMode,
|
|
resolveInstallKind,
|
|
resolveTrackedDirtyPaths,
|
|
rollbackTarget,
|
|
SOURCE_MANUAL_UPGRADE_COMMANDS,
|
|
selectLatestRelease,
|
|
selectLatestReleaseTag,
|
|
upsertEnvAssignments,
|
|
validateUpdateRequest,
|
|
} from "./compose-update.js";
|
|
|
|
const target = {
|
|
composeFiles: ["/srv/rakazo/infra/compose/docker-compose.prod.yml"],
|
|
envFiles: ["/srv/rakazo/.env"],
|
|
};
|
|
|
|
describe("image references", () => {
|
|
it("accepts the tags the publish workflow produces", () => {
|
|
expect(isValidImageTag("latest")).toBe(true);
|
|
expect(isValidImageTag("v1.2.3")).toBe(true);
|
|
expect(isValidImageTag("sha-0123456")).toBe(true);
|
|
expect(isValidImageTag("local-0123456789ab")).toBe(true);
|
|
});
|
|
|
|
it("rejects tags that could become a second argument or a shell fragment", () => {
|
|
expect(isValidImageTag("")).toBe(false);
|
|
expect(isValidImageTag("-rm")).toBe(false);
|
|
expect(isValidImageTag(".hidden")).toBe(false);
|
|
expect(isValidImageTag("v1 v2")).toBe(false);
|
|
expect(isValidImageTag("v1;rm -rf /")).toBe(false);
|
|
expect(isValidImageTag("v1\nv2")).toBe(false);
|
|
expect(isValidImageTag("v1/../../etc")).toBe(false);
|
|
expect(isValidImageTag(`v${"1".repeat(200)}`)).toBe(false);
|
|
});
|
|
|
|
it("accepts registry paths with a host and rejects anything else", () => {
|
|
expect(isValidImageName(OFFICIAL_SERVER_IMAGE)).toBe(true);
|
|
expect(isValidImageName("ghcr.io:5000/owner/app")).toBe(true);
|
|
expect(isValidImageName("rakazo/app")).toBe(true);
|
|
expect(isValidImageName("Ghcr.io/owner/app")).toBe(false);
|
|
expect(isValidImageName("owner/app:tag")).toBe(false);
|
|
expect(isValidImageName("owner/../app")).toBe(false);
|
|
expect(isValidImageName("")).toBe(false);
|
|
});
|
|
|
|
it("refuses to assemble a reference it could not validate", () => {
|
|
expect(imageRef(OFFICIAL_SERVER_IMAGE, "v1.0.0")).toBe(`${OFFICIAL_SERVER_IMAGE}:v1.0.0`);
|
|
expect(() => imageRef(OFFICIAL_SERVER_IMAGE, "-rm")).toThrow(/image tag/);
|
|
expect(() => imageRef("BAD NAME", DEFAULT_IMAGE_TAG)).toThrow(/image name/);
|
|
});
|
|
|
|
it("derives local and per-commit tags from a resolved commit only", () => {
|
|
expect(forkImageTag("0123456789abcdef0123456789abcdef01234567")).toBe(
|
|
"local-0123456789abcdef0123456789abcdef01234567",
|
|
);
|
|
expect(commitImageTag("0123456789abcdef0123456789abcdef01234567")).toBe(
|
|
"sha-0123456789abcdef0123456789abcdef01234567",
|
|
);
|
|
expect(() => forkImageTag("HEAD")).toThrow(/commit/);
|
|
expect(() => commitImageTag("")).toThrow(/commit/);
|
|
});
|
|
});
|
|
|
|
describe("release tag resolution", () => {
|
|
it("parses semver release tags and rejects other tag shapes", () => {
|
|
expect(parseReleaseTag("v1.2.3")).toMatchObject({ major: 1, minor: 2, patch: 3 });
|
|
expect(parseReleaseTag("v1.2.3-rc.1")).toMatchObject({ prerelease: "rc.1" });
|
|
expect(parseReleaseTag("1.2.3")).toBeNull();
|
|
expect(parseReleaseTag("v1.2")).toBeNull();
|
|
expect(parseReleaseTag("latest")).toBeNull();
|
|
});
|
|
|
|
it("orders releases numerically rather than lexically", () => {
|
|
const a = parseReleaseTag("v0.9.0");
|
|
const b = parseReleaseTag("v0.10.0");
|
|
expect(a && b && compareReleaseTags(a, b)).toBeLessThan(0);
|
|
const rc = parseReleaseTag("v1.0.0-rc.1");
|
|
const release = parseReleaseTag("v1.0.0");
|
|
expect(rc && release && compareReleaseTags(rc, release)).toBeLessThan(0);
|
|
});
|
|
|
|
it("reads ls-remote output, collapsing peeled refs", () => {
|
|
const first = "1".repeat(40);
|
|
const second = "2".repeat(40);
|
|
const tagObject = "a".repeat(40);
|
|
const stdout = [
|
|
`${tagObject}\trefs/tags/v0.1.0`,
|
|
`${first}\trefs/tags/v0.1.0^{}`,
|
|
`${second}\trefs/tags/v0.2.0`,
|
|
`${"b".repeat(40)}\trefs/heads/main`,
|
|
`${"c".repeat(40)}\trefs/tags/`,
|
|
"",
|
|
].join("\n");
|
|
expect(parseLsRemoteTags(stdout)).toEqual(["v0.1.0", "v0.2.0"]);
|
|
expect(parseLsRemoteReleases(stdout)).toEqual([
|
|
{ tag: "v0.1.0", commit: first },
|
|
{ tag: "v0.2.0", commit: second },
|
|
]);
|
|
expect(selectLatestRelease(parseLsRemoteReleases(stdout))).toEqual({
|
|
tag: "v0.2.0",
|
|
commit: second,
|
|
});
|
|
});
|
|
|
|
it("drops ls-remote tags that would not be usable as image tags", () => {
|
|
expect(parseLsRemoteTags("aa\trefs/tags/-dangerous\nbb\trefs/tags/v1.0.0")).toEqual(["v1.0.0"]);
|
|
});
|
|
|
|
it("picks the newest stable release and never a pre-release", () => {
|
|
expect(selectLatestReleaseTag(["v0.1.0", "v0.10.2", "v0.9.9"])).toBe("v0.10.2");
|
|
expect(selectLatestReleaseTag(["v1.0.0", "v1.1.0-rc.3"])).toBe("v1.0.0");
|
|
expect(selectLatestReleaseTag(["v2.0.0-rc.1"])).toBeNull();
|
|
expect(selectLatestReleaseTag(["main", "latest"])).toBeNull();
|
|
expect(selectLatestReleaseTag([])).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("strategy and mode selection", () => {
|
|
it("pulls for the official repository and builds for a fork", () => {
|
|
expect(chooseUpdateStrategy({ official: true }).strategy).toBe("pull");
|
|
expect(chooseUpdateStrategy({ official: false }).strategy).toBe("build");
|
|
expect(chooseUpdateStrategy({ official: false }).reason).toMatch(/minutes/);
|
|
});
|
|
|
|
it("prefers the sidecar, falls back to a checkout, and reports when neither exists", () => {
|
|
expect(resolveExecutionMode({ hasUpdater: true, hasCheckout: true })).toEqual({
|
|
mode: "sidecar",
|
|
reason: null,
|
|
});
|
|
expect(resolveExecutionMode({ hasUpdater: false, hasCheckout: true }).mode).toBe("checkout");
|
|
const none = resolveExecutionMode({ hasUpdater: false, hasCheckout: false });
|
|
expect(none.mode).toBe("unavailable");
|
|
expect(none.reason).toMatch(/updater/);
|
|
});
|
|
|
|
it("reports unavailable when self-update is switched off, whatever else is present", () => {
|
|
const off = resolveExecutionMode({ hasUpdater: true, disabled: true });
|
|
expect(off.mode).toBe("unavailable");
|
|
expect(off.reason).toMatch(/switched off/);
|
|
});
|
|
|
|
it("detects sidecar, compose-without-sidecar, and source install kinds", () => {
|
|
expect(
|
|
resolveInstallKind({ updaterUrlConfigured: true, updaterReachable: true, hasCheckout: true }),
|
|
).toEqual({ kind: "sidecar", mode: "sidecar", reason: null });
|
|
const compose = resolveInstallKind({
|
|
updaterUrlConfigured: true,
|
|
updaterReachable: false,
|
|
hasCheckout: true,
|
|
});
|
|
expect(compose.kind).toBe("compose");
|
|
expect(compose.mode).toBe("unavailable");
|
|
expect(compose.reason).toMatch(/sidecar/);
|
|
const source = resolveInstallKind({
|
|
updaterUrlConfigured: false,
|
|
updaterReachable: false,
|
|
hasCheckout: true,
|
|
});
|
|
expect(source.kind).toBe("source");
|
|
expect(source.mode).toBe("checkout");
|
|
expect(manualUpgradeCommands("compose")).toEqual([
|
|
"# Published release tag",
|
|
...COMPOSE_MANUAL_UPGRADE_COMMANDS,
|
|
"# Local tag (rebuild from checkout)",
|
|
"git pull",
|
|
"GIT_SHA=$(git rev-parse HEAD) docker compose --env-file .env -f infra/compose/docker-compose.prod.yml up -d --wait --pull never --build api worker web",
|
|
]);
|
|
expect(manualUpgradeCommands("compose", { imageTag: "local" })).toEqual([
|
|
"git pull",
|
|
"GIT_SHA=$(git rev-parse HEAD) docker compose --env-file .env -f infra/compose/docker-compose.prod.yml up -d --wait --pull never --build api worker web",
|
|
]);
|
|
expect(manualUpgradeCommands("compose", { imageTag: "sha-abc" })).toEqual([
|
|
...COMPOSE_MANUAL_UPGRADE_COMMANDS,
|
|
]);
|
|
expect(manualUpgradeCommands("source")).toEqual([...SOURCE_MANUAL_UPGRADE_COMMANDS]);
|
|
expect(manualUpgradeCommands("sidecar")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("compose argv construction", () => {
|
|
it("passes the compose file and env files as separate arguments", () => {
|
|
expect(composePullArgv(target)).toEqual({
|
|
command: "docker",
|
|
args: [
|
|
"compose",
|
|
"-p",
|
|
DEFAULT_COMPOSE_PROJECT_NAME,
|
|
"--env-file",
|
|
"/srv/rakazo/.env",
|
|
"--file",
|
|
...target.composeFiles,
|
|
"pull",
|
|
"api",
|
|
"worker",
|
|
"web",
|
|
],
|
|
});
|
|
});
|
|
|
|
it("passes one --file per Compose file, in overlay order", () => {
|
|
const overlaid = {
|
|
...target,
|
|
composeFiles: [
|
|
"/srv/rakazo/infra/compose/docker-compose.prod.yml",
|
|
"/srv/rakazo/ops/compose/docker-compose.sandbox.yml",
|
|
],
|
|
};
|
|
for (const invocation of [
|
|
composePullArgv(overlaid),
|
|
composeUpArgv(overlaid),
|
|
composePsArgv(overlaid),
|
|
]) {
|
|
const files = invocation.args.flatMap((arg, index) =>
|
|
arg === "--file" ? [invocation.args[index + 1]] : [],
|
|
);
|
|
expect(files).toEqual(overlaid.composeFiles);
|
|
}
|
|
});
|
|
|
|
it("refuses a target with no Compose file rather than running against the default stack", () => {
|
|
expect(() => composePullArgv({ ...target, composeFiles: [] })).toThrow(/no Compose file/);
|
|
});
|
|
|
|
it("recreates and pulls the target's own services when an overlay adds one", () => {
|
|
const withSupervisor = { ...target, services: ["api", "worker", "web", "supervisor"] };
|
|
expect(composePullArgv(withSupervisor).args.slice(-4)).toEqual([
|
|
"api",
|
|
"worker",
|
|
"web",
|
|
"supervisor",
|
|
]);
|
|
expect(composeUpArgv(withSupervisor).args.slice(-4)).toEqual([
|
|
"api",
|
|
"worker",
|
|
"web",
|
|
"supervisor",
|
|
]);
|
|
});
|
|
|
|
it("falls back to the built-in services when the target names none", () => {
|
|
expect(composeUpArgv({ ...target, services: [] }).args.slice(-3)).toEqual([
|
|
"api",
|
|
"worker",
|
|
"web",
|
|
]);
|
|
});
|
|
|
|
it("carries the overlay files and extra services into the update plan and its recovery", () => {
|
|
const overlaid = {
|
|
...target,
|
|
composeFiles: [
|
|
"/srv/rakazo/infra/compose/docker-compose.prod.yml",
|
|
"/srv/rakazo/ops/compose/docker-compose.sandbox.yml",
|
|
],
|
|
services: ["api", "worker", "web", "supervisor"],
|
|
};
|
|
for (const strategy of ["pull", "build"] as const) {
|
|
const steps = composeUpdatePlan({ strategy, target: overlaid });
|
|
const composeSteps = steps.filter((step) => step.command === "docker");
|
|
expect(composeSteps.length).toBeGreaterThan(0);
|
|
for (const step of composeSteps) {
|
|
expect(step.args.filter((arg) => arg === "--file")).toHaveLength(2);
|
|
expect(step.args).toContain("supervisor");
|
|
}
|
|
}
|
|
});
|
|
|
|
it("passes the project name as its own -p argument so a custom stack is the one updated", () => {
|
|
const named = { ...target, projectName: "operator-stack" };
|
|
for (const invocation of [composePullArgv(named), composeUpArgv(named), composePsArgv(named)]) {
|
|
const projectFlag = invocation.args.indexOf("-p");
|
|
expect(projectFlag).toBeGreaterThanOrEqual(0);
|
|
expect(invocation.args[projectFlag + 1]).toBe("operator-stack");
|
|
expect(
|
|
invocation.args.some((arg) => arg.includes("operator-stack") && arg !== "operator-stack"),
|
|
).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("defaults -p to the name pinned in the compose file", () => {
|
|
expect(DEFAULT_COMPOSE_PROJECT_NAME).toBe("rakazo-prod");
|
|
expect(composePsArgv(target).args.slice(0, 3)).toEqual(["compose", "-p", "rakazo-prod"]);
|
|
});
|
|
|
|
it("refuses a project name that could be parsed as another flag or a second argument", () => {
|
|
expect(() => composeUpArgv({ ...target, projectName: "-f" })).toThrow(/project name/);
|
|
expect(() => composePullArgv({ ...target, projectName: "two words" })).toThrow(/project name/);
|
|
});
|
|
|
|
it("recreates detached, and only builds when asked to", () => {
|
|
expect(composeUpArgv(target).args).toContain("--detach");
|
|
expect(composeUpArgv(target).args).toContain("--wait");
|
|
expect(composeUpArgv(target).args).toContain("--wait-timeout");
|
|
expect(composeUpArgv(target).args).toContain("never");
|
|
expect(composeUpArgv(target).args).toContain("--no-build");
|
|
expect(composeUpArgv(target).args).not.toContain("--build");
|
|
expect(composeUpArgv(target, { build: true }).args).toContain("--build");
|
|
expect(composeUpArgv(target, { build: true }).args).not.toContain("--no-build");
|
|
});
|
|
|
|
it("never recreates the updater, which is the process running the update", () => {
|
|
expect(RECREATED_SERVICES).not.toContain("updater");
|
|
for (const invocation of [composeUpArgv(target), composePullArgv(target)]) {
|
|
expect(invocation.args).not.toContain("updater");
|
|
expect(invocation.args).not.toContain("postgres");
|
|
}
|
|
});
|
|
|
|
it("names the services explicitly so a bare up cannot sweep the whole project", () => {
|
|
const args = composeUpArgv(target).args;
|
|
expect(args.slice(-3)).toEqual(["api", "worker", "web"]);
|
|
});
|
|
});
|
|
|
|
describe("compose project name resolution", () => {
|
|
it("prefers the name Compose injects into running services", () => {
|
|
expect(resolveComposeProjectName({ COMPOSE_PROJECT_NAME: "live-stack" })).toBe("live-stack");
|
|
expect(
|
|
resolveComposeProjectName({
|
|
COMPOSE_PROJECT_NAME: "live-stack",
|
|
RAKAZO_COMPOSE_PROJECT_NAME: "manual-stack",
|
|
}),
|
|
).toBe("live-stack");
|
|
});
|
|
|
|
it("falls back to a dedicated override, then to the name pinned in the compose file", () => {
|
|
expect(resolveComposeProjectName({ RAKAZO_COMPOSE_PROJECT_NAME: "manual-stack" })).toBe(
|
|
"manual-stack",
|
|
);
|
|
expect(resolveComposeProjectName({})).toBe(DEFAULT_COMPOSE_PROJECT_NAME);
|
|
});
|
|
|
|
it("ignores blank values and refuses a name that is not a single argv", () => {
|
|
expect(resolveComposeProjectName({ COMPOSE_PROJECT_NAME: " " })).toBe(
|
|
DEFAULT_COMPOSE_PROJECT_NAME,
|
|
);
|
|
expect(isValidComposeProjectName("-p")).toBe(false);
|
|
expect(() => resolveComposeProjectName({ COMPOSE_PROJECT_NAME: "evil;rm" })).toThrow(
|
|
/project name/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("dirty-tree argv and CRLF filtering", () => {
|
|
it("runs status with autocrlf as its own -c arguments, not interpolated into a shell string", () => {
|
|
expect(gitStatusArgv()).toEqual([
|
|
"--no-optional-locks",
|
|
"-c",
|
|
"core.autocrlf=true",
|
|
"status",
|
|
"--porcelain",
|
|
"--untracked-files=no",
|
|
]);
|
|
expect(gitWorktreeContentDiffArgv()).toEqual([
|
|
"--no-optional-locks",
|
|
"-c",
|
|
"core.autocrlf=true",
|
|
"diff",
|
|
"--name-only",
|
|
"--ignore-cr-at-eol",
|
|
]);
|
|
expect(gitIndexContentDiffArgv()).toEqual([
|
|
"--no-optional-locks",
|
|
"-c",
|
|
"core.autocrlf=true",
|
|
"diff",
|
|
"--cached",
|
|
"--name-only",
|
|
"--ignore-cr-at-eol",
|
|
]);
|
|
expect(gitUntrackedFilesArgv()).toEqual([
|
|
"--no-optional-locks",
|
|
"ls-files",
|
|
"--others",
|
|
"--exclude-standard",
|
|
]);
|
|
});
|
|
|
|
it("reads git name-only output as one path per line", () => {
|
|
expect(parseGitNameOnly("apps/api/src/app.ts\npackages/core/src/x.ts\n")).toEqual([
|
|
"apps/api/src/app.ts",
|
|
"packages/core/src/x.ts",
|
|
]);
|
|
expect(parseGitNameOnly("\n \n")).toEqual([]);
|
|
});
|
|
|
|
it("treats porcelain-only paths as CRLF noise when the ignore-cr diff is empty", () => {
|
|
expect(
|
|
resolveTrackedDirtyPaths({
|
|
porcelainChanged: ["apps/api/src/app.ts", "README.md"],
|
|
contentChanged: [],
|
|
contentDiffOk: true,
|
|
}),
|
|
).toEqual({ dirty: false, dirtyPaths: [] });
|
|
});
|
|
|
|
it("keeps a real content change even when it sits among CRLF-only porcelain noise", () => {
|
|
expect(
|
|
resolveTrackedDirtyPaths({
|
|
porcelainChanged: ["apps/api/src/app.ts", "README.md"],
|
|
contentChanged: ["apps/api/src/app.ts"],
|
|
contentDiffOk: true,
|
|
}),
|
|
).toEqual({ dirty: true, dirtyPaths: ["apps/api/src/app.ts"] });
|
|
});
|
|
|
|
it("keeps a content diff that porcelain parsing missed, so the guard is not weakened", () => {
|
|
expect(
|
|
resolveTrackedDirtyPaths({
|
|
porcelainChanged: [],
|
|
contentChanged: ["packages/core/src/x.ts"],
|
|
contentDiffOk: true,
|
|
}),
|
|
).toEqual({ dirty: true, dirtyPaths: ["packages/core/src/x.ts"] });
|
|
});
|
|
|
|
it("falls back to porcelain when the ignore-cr diffs cannot be read", () => {
|
|
expect(
|
|
resolveTrackedDirtyPaths({
|
|
porcelainChanged: ["apps/api/src/app.ts"],
|
|
contentChanged: [],
|
|
contentDiffOk: false,
|
|
}),
|
|
).toEqual({ dirty: true, dirtyPaths: ["apps/api/src/app.ts"] });
|
|
});
|
|
|
|
it("keeps untracked source files because Docker COPY can include them in a build", () => {
|
|
expect(
|
|
resolveTrackedDirtyPaths({
|
|
porcelainChanged: [],
|
|
contentChanged: [],
|
|
contentDiffOk: true,
|
|
untrackedPaths: ["packages/core/src/untracked.ts"],
|
|
}),
|
|
).toEqual({ dirty: true, dirtyPaths: ["packages/core/src/untracked.ts"] });
|
|
});
|
|
});
|
|
|
|
describe("update plans", () => {
|
|
it("pulls then recreates on the official path, with no migration step", () => {
|
|
const steps = composeUpdatePlan({ strategy: "pull", target });
|
|
expect(steps.map((step) => step.id)).toEqual(["pull", "recreate"]);
|
|
expect(steps.some((step) => step.id === "migrate")).toBe(false);
|
|
for (const step of steps) {
|
|
expect(step.args.slice(0, 3)).toEqual(["compose", "-p", DEFAULT_COMPOSE_PROJECT_NAME]);
|
|
}
|
|
});
|
|
|
|
it("fetches, fast-forwards, then builds on the fork path", () => {
|
|
const steps = composeUpdatePlan({
|
|
strategy: "build",
|
|
target,
|
|
repoUrl: "https://github.com/someone/rakazo",
|
|
branch: "trunk",
|
|
repointRemote: true,
|
|
});
|
|
expect(steps.map((step) => step.id)).toEqual([
|
|
"remote",
|
|
"fetch",
|
|
"checkout",
|
|
"merge",
|
|
"recreate",
|
|
]);
|
|
const merge = steps.find((step) => step.id === "merge");
|
|
expect(merge?.args).toEqual(["merge", "--ff-only", "origin/trunk"]);
|
|
expect(steps.find((step) => step.id === "fetch")?.args).not.toContain("--prune");
|
|
expect(steps.at(-1)?.args).toContain("--build");
|
|
});
|
|
|
|
it("leaves the remote alone when the checkout already points at the chosen repository", () => {
|
|
const steps = composeUpdatePlan({
|
|
strategy: "build",
|
|
target,
|
|
branch: "main",
|
|
repointRemote: false,
|
|
});
|
|
expect(steps.map((step) => step.id)).toEqual(["fetch", "checkout", "merge", "recreate"]);
|
|
});
|
|
|
|
it("keeps the repository URL in argv rather than in any compose input", () => {
|
|
const repoUrl = "https://github.com/someone/rakazo";
|
|
const steps = composeUpdatePlan({
|
|
strategy: "build",
|
|
target,
|
|
repoUrl,
|
|
branch: "main",
|
|
repointRemote: true,
|
|
});
|
|
const remote = steps.find((step) => step.id === "remote");
|
|
expect(remote?.args).toEqual(["remote", "set-url", "origin", repoUrl]);
|
|
const recreate = steps.find((step) => step.id === "recreate");
|
|
expect(recreate?.args.some((arg) => arg.includes(repoUrl))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("rollback", () => {
|
|
it("targets the tag that was running before the last update", () => {
|
|
expect(rollbackTarget({ currentTag: "v1.1.0", previousTag: "v1.0.0" })).toEqual({
|
|
tag: "v1.0.0",
|
|
});
|
|
});
|
|
|
|
it("refuses when there is nothing recorded, nothing usable, or nothing to change", () => {
|
|
expect(rollbackTarget({ currentTag: "v1.1.0", previousTag: null })).toEqual({
|
|
error: expect.stringMatching(/nothing to roll back/),
|
|
});
|
|
expect(rollbackTarget({ currentTag: "v1.1.0", previousTag: "-rm" })).toEqual({
|
|
error: expect.stringMatching(/not a usable tag/),
|
|
});
|
|
expect(rollbackTarget({ currentTag: "v1.0.0", previousTag: "v1.0.0" })).toEqual({
|
|
error: expect.stringMatching(/already running/),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("managed env assignments", () => {
|
|
it("replaces a managed key in place and leaves everything else alone", () => {
|
|
const contents = [
|
|
"# deployment",
|
|
"POSTGRES_PASSWORD=secret",
|
|
"RAKAZO_IMAGE_TAG=v1.0.0",
|
|
"",
|
|
].join("\n");
|
|
expect(upsertEnvAssignments(contents, { RAKAZO_IMAGE_TAG: "v1.1.0" })).toBe(
|
|
["# deployment", "POSTGRES_PASSWORD=secret", "RAKAZO_IMAGE_TAG=v1.1.0", ""].join("\n"),
|
|
);
|
|
});
|
|
|
|
it("appends keys that are not present yet", () => {
|
|
const result = upsertEnvAssignments("POSTGRES_PASSWORD=secret\n", {
|
|
RAKAZO_IMAGE_TAG: "v1.1.0",
|
|
RAKAZO_IMAGE_TAG_PREVIOUS: "v1.0.0",
|
|
});
|
|
expect(result.split("\n")).toEqual([
|
|
"POSTGRES_PASSWORD=secret",
|
|
"",
|
|
"RAKAZO_IMAGE_TAG=v1.1.0",
|
|
"RAKAZO_IMAGE_TAG_PREVIOUS=v1.0.0",
|
|
"",
|
|
]);
|
|
});
|
|
|
|
it("keeps the file's existing line endings", () => {
|
|
const result = upsertEnvAssignments("A=1\r\nRAKAZO_IMAGE_TAG=v1.0.0\r\n", {
|
|
RAKAZO_IMAGE_TAG: "v2.0.0",
|
|
});
|
|
expect(result).toBe("A=1\r\nRAKAZO_IMAGE_TAG=v2.0.0\r\n");
|
|
});
|
|
|
|
it("rewrites every duplicate managed assignment so the last value cannot win", () => {
|
|
const result = upsertEnvAssignments(
|
|
"RAKAZO_IMAGE_TAG=stale\nA=1\nRAKAZO_IMAGE_TAG=still-stale\n",
|
|
{ RAKAZO_IMAGE_TAG: "sha-0123456789abcdef0123456789abcdef01234567" },
|
|
);
|
|
expect(result.match(/RAKAZO_IMAGE_TAG=sha-/g)).toHaveLength(2);
|
|
expect(result).not.toContain("stale");
|
|
});
|
|
|
|
it("refuses values that would inject a second assignment or a compose expression", () => {
|
|
// biome-ignore lint/suspicious/noTemplateCurlyInString: the literal is the hostile input under test
|
|
for (const value of ["v1\nEVIL=1", "v1 v2", "$(id)", "v1;rm -rf /", "${OTHER}"]) {
|
|
expect(() => upsertEnvAssignments("", { RAKAZO_IMAGE_TAG: value })).toThrow(/Refusing/);
|
|
}
|
|
});
|
|
|
|
it("refuses keys outside the managed shape", () => {
|
|
expect(() => upsertEnvAssignments("", { "a b": "1" })).toThrow(/Refusing/);
|
|
});
|
|
});
|
|
|
|
describe("sidecar boundary validation", () => {
|
|
it("normalizes a request the API already validated", () => {
|
|
expect(
|
|
validateUpdateRequest({ repoUrl: "https://github.com/elie222/rakazo.git", branch: " main " }),
|
|
).toEqual({
|
|
request: {
|
|
repoUrl: "https://github.com/elie222/rakazo.git",
|
|
branch: "main",
|
|
official: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("marks a fork as unofficial so the sidecar picks the build path", () => {
|
|
const result = validateUpdateRequest({
|
|
repoUrl: "https://github.com/someone/rakazo",
|
|
branch: "main",
|
|
});
|
|
expect(result).toEqual({
|
|
request: {
|
|
repoUrl: "https://github.com/someone/rakazo",
|
|
branch: "main",
|
|
official: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("re-rejects everything the API would have rejected", () => {
|
|
const cases: Array<{ repoUrl?: unknown; branch?: unknown }> = [
|
|
{ repoUrl: "http://github.com/a/b", branch: "main" },
|
|
{ repoUrl: "file:///etc/passwd", branch: "main" },
|
|
{ repoUrl: "https://user:pw@github.com/a/b", branch: "main" },
|
|
{ repoUrl: "https://github.com/a/b?x=1", branch: "main" },
|
|
{ repoUrl: "https://github.com/../etc", branch: "main" },
|
|
{ repoUrl: "--upload-pack=id", branch: "main" },
|
|
{ repoUrl: "https://github.com/a/b\nrm", branch: "main" },
|
|
{ repoUrl: "https://github.com/a/b", branch: "--exec=id" },
|
|
{ repoUrl: "https://github.com/a/b", branch: "main..evil" },
|
|
{ repoUrl: "https://github.com/a/b", branch: "main\tid" },
|
|
{ repoUrl: 42, branch: "main" },
|
|
{ repoUrl: "https://github.com/a/b", branch: null },
|
|
];
|
|
for (const input of cases) {
|
|
const result = validateUpdateRequest(input);
|
|
expect("error" in result, JSON.stringify(input)).toBe(true);
|
|
}
|
|
});
|
|
});
|