Merge branch 'phase/018-020-assistent'
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
|
||||
/**
|
||||
* Host-zu-Host-Assistent mit der Vorlage „Deployment-Zugang“ (#018–#020), gebauter Stand +
|
||||
* Worker: Benutzer deploy auf B anlegen, Private Key auf A, Public Key auf B, Dry-Run,
|
||||
* ausrollen, Verbindung testen.
|
||||
*/
|
||||
const ROOT_PASSWORT = process.env["SSHKV_TEST_ROOT_PASSWORT"] || "sshkv-test";
|
||||
const PORT = {
|
||||
a: (process.env["SSHKV_TEST_ZIEL_A"] ?? "127.0.0.1:2221").split(":")[1] ?? "2221",
|
||||
b: (process.env["SSHKV_TEST_ZIEL_B"] ?? "127.0.0.1:2222").split(":")[1] ?? "2222",
|
||||
};
|
||||
|
||||
test.setTimeout(240_000);
|
||||
|
||||
function aufZiel(ziel: "ziel-a" | "ziel-b", befehl: string): string {
|
||||
return execFileSync(
|
||||
"docker",
|
||||
["compose", "-f", "docker-compose.test.yml", "exec", "-T", ziel, "sh", "-c", befehl],
|
||||
{
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function hostEinrichten(page: Page, name: string, port: string) {
|
||||
await page.goto("/hosts/neu");
|
||||
await page.getByLabel("Name").fill(name);
|
||||
await page.getByLabel("Adresse").fill("127.0.0.1");
|
||||
await page.getByLabel("Port").fill(port);
|
||||
await page.getByRole("button", { name: "Host anlegen" }).click();
|
||||
await page.getByRole("button", { name: "Host-Key abrufen" }).click();
|
||||
await page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" }).click({ timeout: 30_000 });
|
||||
await page.getByLabel("Passwort", { exact: true }).fill(ROOT_PASSWORT);
|
||||
await page.getByRole("button", { name: "Zugang einrichten" }).click();
|
||||
await expect(page.getByRole("button", { name: "Verbindung prüfen" })).toBeVisible({ timeout: 60_000 });
|
||||
}
|
||||
|
||||
test.beforeAll(() => {
|
||||
const reset =
|
||||
"userdel -r sshkv 2>/dev/null; userdel -r deploy 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true";
|
||||
aufZiel("ziel-a", reset);
|
||||
aufZiel("ziel-b", reset);
|
||||
});
|
||||
|
||||
test("Deployment-Zugang mit dem Assistenten", async ({ page }) => {
|
||||
const kennung = Date.now().toString(36);
|
||||
const hostA = `zz-e2e-a-${kennung}`;
|
||||
const hostB = `zz-e2e-b-${kennung}`;
|
||||
|
||||
await page.goto("/anmelden");
|
||||
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
|
||||
await page.locator('button[data-rolle="admin"]').click();
|
||||
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||
await hostEinrichten(page, hostA, PORT.a);
|
||||
await hostEinrichten(page, hostB, PORT.b);
|
||||
|
||||
// Frische Bestaetigung vorab (Benutzer anlegen verlangt sie); der Mock braucht auth_time.
|
||||
await page.goto("/bestaetigen?ziel=%2Fassistent");
|
||||
await page.getByRole("link", { name: /erneut anmelden/ }).click();
|
||||
await page.locator('button[data-rolle="admin"]').click();
|
||||
const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record<string, unknown>;
|
||||
await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) }));
|
||||
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Host-zu-Host-Assistent" })).toBeVisible();
|
||||
|
||||
// 1. Vorlage & Zweck
|
||||
await page.getByLabel("Vorlage").selectOption({ label: "Deployment-Zugang" });
|
||||
await page.getByLabel("Zweck").fill(`zz-e2e Claude Code Deployment ${kennung}`);
|
||||
await expect(page.getByLabel("Ablaufdatum (optional)")).not.toHaveValue("");
|
||||
await page.getByRole("button", { name: "Weiter" }).click();
|
||||
|
||||
// 2. Quelle
|
||||
await page.getByLabel("Host A").selectOption({ label: hostA });
|
||||
await page.getByRole("button", { name: "Weiter" }).click();
|
||||
|
||||
// 3. Ziel
|
||||
await page.getByLabel("Host B").selectOption({ label: hostB });
|
||||
await expect(page.getByLabel("Benutzer auf B")).toHaveValue("deploy");
|
||||
await expect(page.getByLabel("Einschränkungen in authorized_keys")).toHaveValue('from="127.0.0.1"');
|
||||
await expect(page.getByLabel("Alias auf A")).toHaveValue(`deploy-${hostB}`);
|
||||
// Im Docker-Netz kommt A nicht von 127.0.0.1 und erreicht B unter seinem Dienstnamen.
|
||||
await page.getByLabel("Einschränkungen in authorized_keys").fill("no-agent-forwarding");
|
||||
await page.getByLabel("HostName (aus Sicht von A)").fill("ziel-b");
|
||||
await page.getByLabel("Port").fill("22");
|
||||
await page.getByText("Fingerprint von B ist bestätigt").click();
|
||||
await page.screenshot({ path: "docs/anleitung/bilder/assistent-ziel-desktop.png", fullPage: true });
|
||||
await page.getByRole("button", { name: "Weiter" }).click();
|
||||
|
||||
// 4. Pruefen und vorbereiten
|
||||
await expect(page.getByText(`„deploy“ auf ${hostB} anlegen`)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Vorbereiten" }).click();
|
||||
await expect(page.getByRole("button", { name: "Dry-Run starten" })).toBeVisible({ timeout: 90_000 });
|
||||
|
||||
// 5. Dry-Run, ausrollen, testen
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||
await expect(page.getByText(`deploy@${hostB}:/home/deploy/.ssh/authorized_keys`)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
|
||||
await expect(page.getByText("Ausgerollt.")).toBeVisible({ timeout: 60_000 });
|
||||
await page.getByRole("button", { name: "Verbindung von A nach B testen" }).click();
|
||||
await expect(page.getByText("Erledigt.")).toBeVisible({ timeout: 30_000 });
|
||||
await page.screenshot({ path: "docs/anleitung/bilder/assistent-fertig-desktop.png", fullPage: true });
|
||||
expect(aufZiel("ziel-a", `ssh -o BatchMode=yes deploy-${hostB} id -un`).trim()).toBe("deploy");
|
||||
|
||||
for (const name of [hostA, hostB]) {
|
||||
await page.goto("/hosts");
|
||||
await page.getByRole("link", { name }).first().click();
|
||||
await page.getByRole("button", { name: "Host archivieren" }).click();
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Archivieren", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/hosts$/);
|
||||
}
|
||||
});
|
||||
@@ -21,7 +21,14 @@ export function masterKeyAnlegen(): () => void {
|
||||
}
|
||||
|
||||
export async function vorgangLaufen(
|
||||
art: "HOSTKEY_ABRUFEN" | "BOOTSTRAP" | "TROCKENLAUF" | "AUSROLLEN" | "VERBINDUNGSTEST",
|
||||
art:
|
||||
| "HOSTKEY_ABRUFEN"
|
||||
| "BOOTSTRAP"
|
||||
| "TROCKENLAUF"
|
||||
| "AUSROLLEN"
|
||||
| "VERBINDUNGSTEST"
|
||||
| "ZIELKONTO_ANLEGEN"
|
||||
| "ZIELKONTO_ENTFERNEN",
|
||||
daten: {
|
||||
hostId?: string;
|
||||
eingabe?: object;
|
||||
|
||||
@@ -118,3 +118,29 @@ describe("Zielkonto", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Zielkonto als Vorgang", () => {
|
||||
it("legt an, merkt sich die Herkunft und entfernt den Benutzer wieder", async () => {
|
||||
const { prisma } = await import("@/lib/db");
|
||||
const { vorgangLaufen } = await import("./hilfen/host");
|
||||
const host = await eingerichteterHost(zielB, "ziel-b");
|
||||
const anlegen = await vorgangLaufen("ZIELKONTO_ANLEGEN", {
|
||||
hostId: host.id,
|
||||
eingabe: { benutzer: "deploy", modus: "VOLL", befehle: [] },
|
||||
});
|
||||
expect(anlegen.fehler).toBeNull();
|
||||
const konto = await prisma.zielkonto.findFirstOrThrow({ where: { hostId: host.id } });
|
||||
expect(konto).toMatchObject({ benutzername: "deploy", vonSshkvAngelegt: true, sudoModus: "VOLL" });
|
||||
expect(konto.sudoersInhalt).toContain("deploy ALL=(ALL:ALL) NOPASSWD: ALL");
|
||||
|
||||
const entfernen = await vorgangLaufen("ZIELKONTO_ENTFERNEN", {
|
||||
hostId: host.id,
|
||||
eingabe: { benutzer: "deploy" },
|
||||
});
|
||||
expect(entfernen.fehler).toBeNull();
|
||||
expect((await befehlAlsRoot(zielB, "id deploy 2>&1")).stdout).toMatch(/no such user/);
|
||||
expect(
|
||||
(await prisma.zielkonto.findFirstOrThrow({ where: { hostId: host.id } })).entferntAm,
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user