feat: release 2.7.0-rc3
This commit is contained in:
@@ -10,6 +10,26 @@ import {
|
||||
} from "../../lib/scaffold";
|
||||
import { resolveAppInput } from "../../command/add";
|
||||
import { readCompose } from "../../lib/yaml";
|
||||
import type { ScaffoldQuestion } from "../../lib/scaffold-prompts";
|
||||
import { listDockerfileTemplates } from "../../lib/scaffold-templates";
|
||||
|
||||
function snippetValues(
|
||||
question: ScaffoldQuestion,
|
||||
overrides: Record<string, string> = {},
|
||||
) {
|
||||
return {
|
||||
values: {
|
||||
...Object.fromEntries(
|
||||
(question.fields ?? []).map((field) => [
|
||||
field.name,
|
||||
field.initial ?? "",
|
||||
]),
|
||||
),
|
||||
...overrides,
|
||||
},
|
||||
result: "services:\n malicious:\n image: ignored-preview",
|
||||
};
|
||||
}
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
@@ -156,3 +176,208 @@ test("rejects path traversal and symlink outside the project", async () => {
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("interactive image flow selects source before path and omits blank fields from generated YAML", async () => {
|
||||
const cwd = await root();
|
||||
const questions: ScaffoldQuestion[] = [];
|
||||
const app = await resolveAppInput(cwd, {}, async (question) => {
|
||||
questions.push(question);
|
||||
if (question.name === "source") {
|
||||
expect(question.initial).toBe(0);
|
||||
return "image";
|
||||
}
|
||||
if (question.name === "image") {
|
||||
expect(question.initial).toBe("nginx:stable");
|
||||
return "example/web:1";
|
||||
}
|
||||
if (question.name === "path") return ".";
|
||||
expect(question.type).toBe("snippet");
|
||||
expect(question.template).not.toContain("managedBy");
|
||||
expect(question.template).toContain('image: "example/web:1"');
|
||||
expect(question.fields?.some((field) => field.name === "image")).toBe(
|
||||
false,
|
||||
);
|
||||
return snippetValues(question, {
|
||||
name: "web",
|
||||
postgres: "",
|
||||
s3: "",
|
||||
cpu: "",
|
||||
memory: "",
|
||||
});
|
||||
});
|
||||
expect(questions.map(({ type }) => type)).toEqual([
|
||||
"select",
|
||||
"input",
|
||||
"autocomplete",
|
||||
"snippet",
|
||||
]);
|
||||
await createCompose(cwd, "demo", app);
|
||||
const compose = await readCompose(join(cwd, "compose.yml"));
|
||||
expect(compose.services?.web).toEqual({
|
||||
image: "example/web:1",
|
||||
deploy: { replicas: 1 },
|
||||
});
|
||||
expect(compose.services?.malicious).toBeUndefined();
|
||||
});
|
||||
|
||||
test("auto flow accepts CNB URI and valid custom directory while keeping the root context rule", async () => {
|
||||
const cwd = await root();
|
||||
await mkdir(join(cwd, "web"));
|
||||
await mkdir(join(cwd, "custom"));
|
||||
await writeFile(join(cwd, "web", "package.json"), "{}");
|
||||
const names: string[] = [];
|
||||
const app = await resolveAppInput(cwd, {}, async (question) => {
|
||||
names.push(question.name);
|
||||
if (question.name === "source") {
|
||||
expect(question.initial).toBe(1);
|
||||
return "auto";
|
||||
}
|
||||
if (question.name === "cnb") {
|
||||
expect(question.validate?.("https://example.com/buildpack.tgz")).not.toBe(
|
||||
true,
|
||||
);
|
||||
expect(question.validate?.("")).toBe(true);
|
||||
return "https://github.com/example/buildpack/releases/download/v1/web.cnb";
|
||||
}
|
||||
if (question.name === "path") {
|
||||
expect(question.choices?.map(({ name }) => name)).toEqual(["web", "."]);
|
||||
expect(await question.validate?.("../outside")).not.toBe(true);
|
||||
expect(await question.validate?.("custom")).toBe(true);
|
||||
expect(
|
||||
await question.suggest?.("custom", question.choices!),
|
||||
).toContainEqual({ name: "custom", message: "custom" });
|
||||
return "custom";
|
||||
}
|
||||
return snippetValues(question, { name: "custom" });
|
||||
});
|
||||
expect(names).toEqual(["source", "cnb", "path", "compose"]);
|
||||
await createCompose(cwd, "demo", app);
|
||||
expect(
|
||||
(await readCompose(join(cwd, "compose.yml"))).services?.custom,
|
||||
).toMatchObject({
|
||||
build:
|
||||
"auto:https://github.com/example/buildpack/releases/download/v1/web.cnb",
|
||||
"x-kuber-build-context": "custom",
|
||||
});
|
||||
const rootApp = await resolveAppInput(cwd, {
|
||||
source: "auto",
|
||||
path: ".",
|
||||
name: "root",
|
||||
nonInteractive: true,
|
||||
cnb: "https://github.com/example/buildpack/releases/download/v1/root.cnb",
|
||||
});
|
||||
await addAppToCompose(cwd, rootApp);
|
||||
expect((await readCompose(join(cwd, "compose.yml"))).services?.root).toEqual({
|
||||
build:
|
||||
"auto:https://github.com/example/buildpack/releases/download/v1/root.cnb",
|
||||
});
|
||||
});
|
||||
|
||||
test("template branch exposes all eight paired templates and locks explicitly flagged fields", async () => {
|
||||
const cwd = await root();
|
||||
const names: string[] = [];
|
||||
const app = await resolveAppInput(
|
||||
cwd,
|
||||
{ path: ".", name: "fixed", cpu: "0.5" },
|
||||
async (question) => {
|
||||
names.push(question.name);
|
||||
if (question.name === "source") return "template";
|
||||
if (question.name === "template") {
|
||||
expect(question.choices?.map(({ name }) => name)).toEqual(
|
||||
listDockerfileTemplates().map(({ id }) => id),
|
||||
);
|
||||
expect(question.choices).toHaveLength(8);
|
||||
return "bun-service";
|
||||
}
|
||||
expect(
|
||||
question.fields?.some(({ name }) => name === "name" || name === "cpu"),
|
||||
).toBe(false);
|
||||
return snippetValues(question, {
|
||||
name: "ignored",
|
||||
cpu: "99",
|
||||
postgres: "user/db",
|
||||
s3: "key/bucket",
|
||||
memory: "512m",
|
||||
replicas: "2",
|
||||
});
|
||||
},
|
||||
);
|
||||
expect(names).toEqual(["source", "template", "compose"]);
|
||||
expect(app).toMatchObject({
|
||||
name: "fixed",
|
||||
cpu: "0.5",
|
||||
source: { kind: "template", template: "bun-service" },
|
||||
postgres: "user/db",
|
||||
s3: "key/bucket",
|
||||
memory: "512m",
|
||||
replicas: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("add preview contains literal existing names; collision is corrected in Snippet and existing content is preserved", async () => {
|
||||
const cwd = await root();
|
||||
const original =
|
||||
"name: demo\nservices:\n existing:\n image: private:1 # retain\n app:\n image: private:2\nx-owner: same\n";
|
||||
const file = join(cwd, "compose.yml");
|
||||
await writeFile(file, original);
|
||||
const app = await resolveAppInput(
|
||||
cwd,
|
||||
{ source: "image", image: "nginx", path: "." },
|
||||
async (question) => {
|
||||
expect(question.type).toBe("snippet");
|
||||
expect(question.template).toContain(
|
||||
"services:\n existing: ...\n app: ...\n ${name}:",
|
||||
);
|
||||
expect(question.template).not.toContain("private:1");
|
||||
const name = question.fields?.find(({ name }) => name === "name");
|
||||
expect(name?.validate?.("app")).toBe("Service app already exists");
|
||||
expect(name?.validate?.("worker")).toBe(true);
|
||||
expect(
|
||||
question.fields
|
||||
?.find(({ name }) => name === "replicas")
|
||||
?.validate?.("0"),
|
||||
).toContain("positive integer");
|
||||
return snippetValues(question, { name: "worker" });
|
||||
},
|
||||
);
|
||||
await addAppToCompose(cwd, app);
|
||||
const updated = await readFile(file, "utf8");
|
||||
expect(updated).toContain(
|
||||
" existing:\n image: private:1 # retain\n app:\n image: private:2\n",
|
||||
);
|
||||
expect(updated).not.toContain("...");
|
||||
expect((await readCompose(file)).services?.worker?.image).toBe("nginx");
|
||||
await expect(
|
||||
resolveAppInput(
|
||||
cwd,
|
||||
{ source: "image", image: "nginx", path: "." },
|
||||
async (question) => snippetValues(question, { name: "app" }),
|
||||
),
|
||||
).rejects.toThrow("already exists");
|
||||
expect(await readFile(file, "utf8")).toBe(updated);
|
||||
});
|
||||
|
||||
test("cancelled add and a service appearing after the preview do not overwrite Compose", async () => {
|
||||
const cwd = await root();
|
||||
const file = await createCompose(cwd, "demo", {
|
||||
name: "original",
|
||||
path: ".",
|
||||
source: { kind: "image", image: "nginx" },
|
||||
});
|
||||
const original = await readFile(file, "utf8");
|
||||
await expect(
|
||||
resolveAppInput(cwd, {}, async () => {
|
||||
throw new Error("cancelled");
|
||||
}),
|
||||
).rejects.toThrow("cancelled");
|
||||
expect(await readFile(file, "utf8")).toBe(original);
|
||||
const app = await resolveAppInput(
|
||||
cwd,
|
||||
{ source: "image", image: "nginx", path: "." },
|
||||
async (question) => snippetValues(question, { name: "worker" }),
|
||||
);
|
||||
await addAppToCompose(cwd, app);
|
||||
const updated = await readFile(file, "utf8");
|
||||
await expect(addAppToCompose(cwd, app)).rejects.toThrow("already exists");
|
||||
expect(await readFile(file, "utf8")).toBe(updated);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { Writable } from "node:stream";
|
||||
import {
|
||||
createLoginPrompt,
|
||||
interactiveLogin,
|
||||
type LoginPrompt,
|
||||
} from "../../command/auth";
|
||||
import { KuberApiError } from "../../lib/api";
|
||||
import type { KuberSession } from "../../lib/session";
|
||||
|
||||
const session: KuberSession = {
|
||||
token: "test-token",
|
||||
expiresAt: "2099-01-01T00:00:00Z",
|
||||
user: { username: "alice", roles: ["user"] },
|
||||
};
|
||||
|
||||
async function start(prompt: LoginPrompt) {
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
(prompt as unknown as EventEmitter).once("run", resolve);
|
||||
});
|
||||
const result = prompt.run();
|
||||
await ready;
|
||||
return { result };
|
||||
}
|
||||
|
||||
async function credentials(
|
||||
prompt: LoginPrompt,
|
||||
username: string,
|
||||
password: string,
|
||||
) {
|
||||
for (const choice of prompt.choices) {
|
||||
choice.input = choice.value =
|
||||
choice.name === "username" ? username : password;
|
||||
}
|
||||
await prompt.render();
|
||||
}
|
||||
|
||||
describe("BasicAuth login", () => {
|
||||
test("authenticates once against the supplied API and preserves persistence", async () => {
|
||||
const calls: unknown[][] = [];
|
||||
const prompt = createLoginPrompt(
|
||||
"alice",
|
||||
true,
|
||||
async (...args) => {
|
||||
calls.push(args);
|
||||
return session;
|
||||
},
|
||||
{ show: false },
|
||||
);
|
||||
const { result } = await start(prompt);
|
||||
expect(prompt.values.username).toBe("alice");
|
||||
await credentials(prompt, " alice ", "sensitive-password");
|
||||
await Promise.all([prompt.submit(), prompt.submit()]);
|
||||
expect(await result).toEqual(session);
|
||||
expect(calls).toEqual([["alice", "sensitive-password", true]]);
|
||||
expect(prompt.values.password).toBe("");
|
||||
});
|
||||
|
||||
test("incorrect authentication closes the prompt and retains the CLI API error", async () => {
|
||||
const error = new KuberApiError("Incorrect credentials", 401);
|
||||
const prompt = createLoginPrompt(
|
||||
"",
|
||||
false,
|
||||
async () => {
|
||||
throw error;
|
||||
},
|
||||
{ show: false },
|
||||
);
|
||||
const { result } = await start(prompt);
|
||||
const rejected = result.catch((error: unknown) => error);
|
||||
await credentials(prompt, "alice", "secret");
|
||||
await prompt.submit();
|
||||
expect(await rejected).toBe(error);
|
||||
expect(prompt.state.closed).toBe(true);
|
||||
expect(prompt.values.password).toBe("");
|
||||
});
|
||||
|
||||
test("cancellation rejects without calling authentication", async () => {
|
||||
let calls = 0;
|
||||
const prompt = createLoginPrompt(
|
||||
"",
|
||||
false,
|
||||
async () => {
|
||||
calls++;
|
||||
return session;
|
||||
},
|
||||
{ show: false },
|
||||
);
|
||||
const { result } = await start(prompt);
|
||||
const rejected = result.catch((error: unknown) => error);
|
||||
await credentials(prompt, "alice", "secret");
|
||||
await prompt.cancel();
|
||||
expect(await rejected).toMatchObject({ message: "Login cancelled" });
|
||||
expect(calls).toBe(0);
|
||||
expect(prompt.values.password).toBe("");
|
||||
});
|
||||
|
||||
test.each(["submit", "cancel"] as const)(
|
||||
"masks password while editing and never prints it on %s",
|
||||
async (action) => {
|
||||
let output = "";
|
||||
const stdout = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
output += chunk.toString();
|
||||
callback();
|
||||
},
|
||||
}) as unknown as NodeJS.WriteStream;
|
||||
const prompt = createLoginPrompt("", false, async () => session, {
|
||||
show: false,
|
||||
stdout,
|
||||
});
|
||||
const { result } = await start(prompt);
|
||||
const settled = result.catch(() => undefined);
|
||||
await credentials(prompt, "alice", "never-print-this");
|
||||
// show:false disables terminal listeners; enable writes only after initialization.
|
||||
(prompt as unknown as { state: { show: boolean } }).state.show = true;
|
||||
await prompt.render();
|
||||
expect(output).toContain("password");
|
||||
expect(output).toContain("*".repeat("never-print-this".length));
|
||||
expect(output).not.toContain("never-print-this");
|
||||
await prompt[action]();
|
||||
await settled;
|
||||
expect(output).not.toContain("never-print-this");
|
||||
},
|
||||
);
|
||||
|
||||
test("empty username fails before contacting authentication", async () => {
|
||||
let calls = 0;
|
||||
const prompt = createLoginPrompt(
|
||||
"",
|
||||
false,
|
||||
async () => {
|
||||
calls++;
|
||||
return session;
|
||||
},
|
||||
{ show: false },
|
||||
);
|
||||
const { result } = await start(prompt);
|
||||
const rejected = result.catch((error: unknown) => error);
|
||||
await prompt.submit();
|
||||
expect(await rejected).toMatchObject({ message: "Username is required" });
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
test("noninteractive onboarding and login fail before creating a prompt", async () => {
|
||||
let calls = 0;
|
||||
await expect(
|
||||
interactiveLogin("alice", true, {
|
||||
isTTY: false,
|
||||
prompt: () => {
|
||||
calls++;
|
||||
throw new Error("Unexpected prompt");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Login requires an interactive terminal");
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
test("onboarding returns the session using the shared prompt", async () => {
|
||||
const result = await interactiveLogin(" alice ", true, {
|
||||
isTTY: true,
|
||||
prompt: (username, persistent) => {
|
||||
expect(username).toBe("alice");
|
||||
expect(persistent).toBe(true);
|
||||
const prompt = createLoginPrompt(
|
||||
username,
|
||||
persistent,
|
||||
async () => session,
|
||||
{ show: false },
|
||||
);
|
||||
(prompt as unknown as EventEmitter).once("run", () => {
|
||||
void prompt.submit();
|
||||
});
|
||||
return prompt;
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(session);
|
||||
});
|
||||
});
|
||||
+285
-3
@@ -7,6 +7,7 @@ import { initializeProject } from "../../command/init";
|
||||
import { managedHeader } from "../../lib/scaffold";
|
||||
import { readTrust, resolveTrustIdentity, updateTrust } from "../../lib/trust";
|
||||
import { KuberApiError } from "../../lib/api";
|
||||
import type { ScaffoldQuestion } from "../../lib/scaffold-prompts";
|
||||
|
||||
const roots: string[] = [];
|
||||
const originalConfig = process.env.XDG_CONFIG_HOME;
|
||||
@@ -24,6 +25,189 @@ async function root() {
|
||||
}
|
||||
|
||||
describe("kuber init", () => {
|
||||
test("interactive YAML fields finish before login and automatic trust", async () => {
|
||||
const cwd = await root();
|
||||
const events: string[] = [];
|
||||
const session = {
|
||||
token: "test",
|
||||
expiresAt: "2099-01-01",
|
||||
user: { username: "a", roles: [] },
|
||||
};
|
||||
const log = spyOn(console, "log").mockImplementation(() => {});
|
||||
try {
|
||||
await initializeProject(
|
||||
cwd,
|
||||
{},
|
||||
{
|
||||
prompt: async (question) => {
|
||||
events.push(question.name);
|
||||
expect(await Bun.file(join(cwd, "compose.yml")).exists()).toBe(
|
||||
false,
|
||||
);
|
||||
if (question.name === "source") return "image";
|
||||
if (question.name === "image") return "nginx:stable";
|
||||
if (question.name === "path") return ".";
|
||||
expect(question.type).toBe("snippet");
|
||||
expect(question.template).toStartWith(
|
||||
"name: ${project}\nservices:\n ${name}:",
|
||||
);
|
||||
expect(question.template).not.toContain("managedBy");
|
||||
const project = question.fields?.find(
|
||||
({ name }) => name === "project",
|
||||
);
|
||||
expect(project?.validate?.("Bad Project")).not.toBe(true);
|
||||
expect(project?.validate?.("demo")).toBe(true);
|
||||
return {
|
||||
values: { project: "demo", name: "web", replicas: "1" },
|
||||
result: "malicious preview",
|
||||
};
|
||||
},
|
||||
session: async () => {
|
||||
events.push("session");
|
||||
return undefined;
|
||||
},
|
||||
login: async () => {
|
||||
events.push("login");
|
||||
expect(await Bun.file(join(cwd, "compose.yml")).exists()).toBe(
|
||||
false,
|
||||
);
|
||||
return session;
|
||||
},
|
||||
request: async (_path, init) => {
|
||||
events.push(init?.method ?? "GET");
|
||||
return (init?.method ? undefined : { fingerprints: [] }) as never;
|
||||
},
|
||||
persistTrust: async () => {
|
||||
events.push("trust");
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(events).toEqual([
|
||||
"source",
|
||||
"image",
|
||||
"path",
|
||||
"compose",
|
||||
"session",
|
||||
"login",
|
||||
"GET",
|
||||
"POST",
|
||||
"trust",
|
||||
]);
|
||||
const content = await readFile(join(cwd, "compose.yml"), "utf8");
|
||||
expect(content.split("\n")[0]).toBe(managedHeader);
|
||||
expect(YAML.parse(content)).toEqual({
|
||||
managedBy: "kuber",
|
||||
name: "demo",
|
||||
services: { web: { image: "nginx:stable", deploy: { replicas: 1 } } },
|
||||
});
|
||||
expect(content).not.toContain("malicious");
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("flagged project and configured project bypass editable project field; existing login is reused", async () => {
|
||||
for (const configured of [false, true]) {
|
||||
const cwd = await root();
|
||||
if (configured)
|
||||
await writeFile(
|
||||
join(cwd, ".kuberrc.ts"),
|
||||
'export default { project: "fixed" };\n',
|
||||
);
|
||||
let logins = 0;
|
||||
const questions: ScaffoldQuestion[] = [];
|
||||
const log = spyOn(console, "log").mockImplementation(() => {});
|
||||
try {
|
||||
await initializeProject(
|
||||
cwd,
|
||||
{
|
||||
source: "auto",
|
||||
cnb: "",
|
||||
path: ".",
|
||||
...(configured ? {} : { project: "fixed" }),
|
||||
},
|
||||
{
|
||||
prompt: async (question) => {
|
||||
questions.push(question);
|
||||
expect(question.template).toStartWith('name: "fixed"');
|
||||
expect(
|
||||
question.fields?.some(({ name }) => name === "project"),
|
||||
).toBe(false);
|
||||
return {
|
||||
values: { project: "ignored", name: "web", replicas: "1" },
|
||||
};
|
||||
},
|
||||
session: async () => ({
|
||||
token: "test",
|
||||
expiresAt: "2099-01-01",
|
||||
user: { username: "a", roles: [] },
|
||||
}),
|
||||
login: async () => {
|
||||
logins++;
|
||||
throw new Error("unneeded login");
|
||||
},
|
||||
request: async (_path, init) =>
|
||||
(init?.method ? undefined : { fingerprints: [] }) as never,
|
||||
persistTrust: async () => {},
|
||||
},
|
||||
);
|
||||
expect(questions.map(({ type }) => type)).toEqual(["snippet"]);
|
||||
expect(logins).toBe(0);
|
||||
expect(
|
||||
YAML.parse(await readFile(join(cwd, "compose.yml"), "utf8")),
|
||||
).toMatchObject({
|
||||
name: "fixed",
|
||||
services: { web: { build: "auto" } },
|
||||
});
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("cancel or invalid snippet performs no login, trust, or filesystem writes", async () => {
|
||||
for (const failure of ["cancel", "invalid"]) {
|
||||
const cwd = await root();
|
||||
let downstream = 0;
|
||||
await expect(
|
||||
initializeProject(
|
||||
cwd,
|
||||
{ source: "template", template: "bun-service", path: "." },
|
||||
{
|
||||
prompt: async () => {
|
||||
if (failure === "cancel") throw new Error("cancelled");
|
||||
return {
|
||||
values: {
|
||||
project: "demo",
|
||||
name: "web",
|
||||
cpu: "-1",
|
||||
replicas: "1",
|
||||
},
|
||||
};
|
||||
},
|
||||
session: async () => {
|
||||
downstream++;
|
||||
return undefined;
|
||||
},
|
||||
login: async () => {
|
||||
downstream++;
|
||||
throw new Error("unexpected login");
|
||||
},
|
||||
request: async () => {
|
||||
downstream++;
|
||||
return undefined as never;
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(
|
||||
failure === "cancel" ? "cancelled" : "CPU must be a positive",
|
||||
);
|
||||
expect(downstream).toBe(0);
|
||||
for (const name of ["compose.yml", "Dockerfile", ".dockerignore"])
|
||||
expect(await Bun.file(join(cwd, name)).exists()).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("uses an existing configured project for Compose and trust", async () => {
|
||||
const cwd = await root();
|
||||
await writeFile(
|
||||
@@ -146,14 +330,14 @@ describe("kuber init", () => {
|
||||
};
|
||||
},
|
||||
request: async (_path, init) => {
|
||||
if (++attempts === 1) throw new KuberApiError("revoked", 401);
|
||||
if (++attempts === 2) throw new KuberApiError("revoked", 401);
|
||||
return (
|
||||
init?.method === "POST" ? undefined : { fingerprints: [] }
|
||||
) as never;
|
||||
},
|
||||
},
|
||||
);
|
||||
expect([attempts, logins]).toEqual([3, 1]);
|
||||
expect([attempts, logins]).toEqual([4, 1]);
|
||||
expect(await readTrust()).toHaveLength(1);
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
@@ -361,7 +545,7 @@ describe("kuber init", () => {
|
||||
request: async (_path, init) => {
|
||||
calls.push(init?.method ?? "GET");
|
||||
if (init?.method === "POST")
|
||||
throw new KuberApiError("grant failed", 503);
|
||||
throw new KuberApiError("grant failed", 400);
|
||||
return { fingerprints: [] } as never;
|
||||
},
|
||||
},
|
||||
@@ -374,6 +558,104 @@ describe("kuber init", () => {
|
||||
expect(await Bun.file(join(cwd, ".dockerignore")).exists()).toBe(false);
|
||||
});
|
||||
|
||||
test("committed POST with lost response keeps generated files after reconciliation", async () => {
|
||||
const cwd = await root();
|
||||
const identity = await resolveTrustIdentity("demo", cwd);
|
||||
const trusted = new Set<string>();
|
||||
const calls: string[] = [];
|
||||
await expect(
|
||||
initializeProject(
|
||||
cwd,
|
||||
{
|
||||
nonInteractive: true,
|
||||
project: "demo",
|
||||
source: "template",
|
||||
template: "static-nginx",
|
||||
},
|
||||
{
|
||||
session: async () => ({
|
||||
token: "test",
|
||||
expiresAt: "2099-01-01",
|
||||
user: { username: "a", roles: [] },
|
||||
}),
|
||||
request: async (_path, init) => {
|
||||
calls.push(init?.method ?? "GET");
|
||||
if (init?.method === "POST") {
|
||||
trusted.add(identity.fingerprint);
|
||||
throw new Error("socket closed after commit");
|
||||
}
|
||||
return { fingerprints: [...trusted] } as never;
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/registration succeeded.*files were kept/);
|
||||
expect(calls).toEqual(["GET", "POST", "GET"]);
|
||||
expect(await Bun.file(join(cwd, "compose.yml")).exists()).toBe(true);
|
||||
expect(await Bun.file(join(cwd, "Dockerfile")).exists()).toBe(true);
|
||||
expect(await Bun.file(join(cwd, ".dockerignore")).exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("unavailable reconciliation keeps generated files and explains repair", async () => {
|
||||
const cwd = await root();
|
||||
const calls: string[] = [];
|
||||
await expect(
|
||||
initializeProject(
|
||||
cwd,
|
||||
{
|
||||
nonInteractive: true,
|
||||
project: "demo",
|
||||
},
|
||||
{
|
||||
session: async () => ({
|
||||
token: "test",
|
||||
expiresAt: "2099-01-01",
|
||||
user: { username: "a", roles: [] },
|
||||
}),
|
||||
request: async (_path, init) => {
|
||||
calls.push(init?.method ?? "GET");
|
||||
if (init?.method === "POST") throw new Error("response lost");
|
||||
if (calls.length === 3)
|
||||
throw new Error("trust service unavailable");
|
||||
return { fingerprints: [] } as never;
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/Could not confirm.*kuber trust status.*kuber trust/);
|
||||
expect(calls).toEqual(["GET", "POST", "GET"]);
|
||||
expect(await Bun.file(join(cwd, "compose.yml")).exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("concurrent grant found during reconciliation keeps generated files", async () => {
|
||||
const cwd = await root();
|
||||
const identity = await resolveTrustIdentity("demo", cwd);
|
||||
const calls: string[] = [];
|
||||
await expect(
|
||||
initializeProject(
|
||||
cwd,
|
||||
{
|
||||
nonInteractive: true,
|
||||
project: "demo",
|
||||
},
|
||||
{
|
||||
session: async () => ({
|
||||
token: "test",
|
||||
expiresAt: "2099-01-01",
|
||||
user: { username: "a", roles: [] },
|
||||
}),
|
||||
request: async (_path, init) => {
|
||||
calls.push(init?.method ?? "GET");
|
||||
if (init?.method === "POST") throw new Error("response lost");
|
||||
return {
|
||||
fingerprints: calls.length === 3 ? [identity.fingerprint] : [],
|
||||
} as never;
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/registration succeeded.*files were kept/);
|
||||
expect(calls).toEqual(["GET", "POST", "GET"]);
|
||||
expect(await Bun.file(join(cwd, "compose.yml")).exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("existing Compose file is not overwritten and no grant is attempted", async () => {
|
||||
const cwd = await root();
|
||||
const composePath = join(cwd, "compose.yml");
|
||||
|
||||
Reference in New Issue
Block a user