test: SSH-Testziele und Mock-OIDC fuer Integrationstests (#011)

Bootstrap, Host-Key-Pruefung und spaeteres Ausrollen brauchen echte
sshd-Gegenstellen. Zwei Debian-13-Container (ziel-a, ziel-b) in einem
gemeinsamen Netz bilden frische Hosts vor dem Bootstrap nach: root- und
Passwort-Login sind absichtlich offen, Host-Keys entstehen erst beim
Start, und `hostkeys-tauschen` erlaubt den Test "geaenderter Host-Key
wird abgelehnt" ohne Neuerstellen des Containers.

Die Testhilfen lesen dieselben Adress-Variablen wie die Compose-Datei
und lassen Tests mit Hinweis auf den Startbefehl fehlschlagen, statt
sie still zu ueberspringen - ein uebersprungener Integrationstest sieht
sonst wie ein bestandener aus.

Der Mock-OIDC-Anbieter ersetzt authentik in der Entwicklung (PLAN.md,
Entscheidung 7). Bei interaktivem Login uebernimmt er nur Claims aus dem
Formular, daher belegt eine eigene Login-Seite email, name und groups
vor und laesst die Gruppen frei waehlen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:13:56 +02:00
co-authored by Claude Opus 5.5
parent 224386106b
commit e89112a761
11 changed files with 614 additions and 6 deletions
+124
View File
@@ -0,0 +1,124 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mock-OIDC – Anmeldung (nur Entwicklung)</title>
<style>
body {
font-family: system-ui, sans-serif;
background: #111418;
color: #e6e8eb;
margin: 0;
padding: 2rem 1rem;
}
main {
max-width: 34rem;
margin: 0 auto;
}
h1 {
font-size: 1.3rem;
}
p.hinweis {
color: #f0b429;
font-size: 0.9rem;
}
label {
display: block;
margin: 1rem 0 0.3rem;
font-weight: 600;
}
input,
textarea {
width: 100%;
box-sizing: border-box;
background: #1b2027;
color: inherit;
border: 1px solid #39414d;
border-radius: 6px;
padding: 0.5rem;
font-family: ui-monospace, monospace;
font-size: 0.9rem;
}
.rollen {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 1rem;
}
button {
background: #2a3240;
color: inherit;
border: 1px solid #39414d;
border-radius: 6px;
padding: 0.4rem 0.8rem;
cursor: pointer;
}
button[type="submit"] {
background: #2f6fde;
border-color: #2f6fde;
margin-top: 1rem;
width: 100%;
padding: 0.6rem;
}
</style>
</head>
<body>
<main>
<h1>Mock-OIDC-Anmeldung</h1>
<p class="hinweis">
Nur für die Entwicklung (#011). Benutzername wird zu <code>sub</code>, die Claims unten landen im
ID-Token und unter <code>/userinfo</code>.
</p>
<!-- Ohne action postet das Formular an die /authorize-URL samt Query zurück –
genau das erwartet mock-oauth2-server. -->
<form method="post">
<label for="username">Benutzername (sub)</label>
<input id="username" name="username" required value="admin" autocomplete="off" />
<div class="rollen">
<button type="button" data-rolle="admin">Admin</button>
<button type="button" data-rolle="operator">Operator</button>
<button type="button" data-rolle="reader">Reader</button>
<button type="button" data-rolle="ohne">Ohne Gruppe</button>
</div>
<label for="claims">Claims (JSON, frei änderbar)</label>
<textarea id="claims" name="claims" rows="12"></textarea>
<button type="submit">Anmelden</button>
</form>
</main>
<script>
// Der Mock übernimmt bei interaktivem Login nur die Claims aus diesem Formular
// (tokenCallbacks greifen dann nicht). Deshalb wird hier ein vollständiger
// Standardsatz vorbelegt; Gruppen lassen sich frei ändern.
const gruppen = {
admin: ["sshkv-admins"],
operator: ["sshkv-operators"],
reader: ["sshkv-readers"],
ohne: [],
};
const benutzer = document.getElementById("username");
const claims = document.getElementById("claims");
function fuellen(rolle) {
const name = benutzer.value || rolle;
claims.value = JSON.stringify(
{
email: name + "@sshkv.test",
email_verified: true,
name: name.charAt(0).toUpperCase() + name.slice(1) + " (Mock)",
preferred_username: name,
groups: gruppen[rolle],
},
null,
2,
);
}
document.querySelectorAll("button[data-rolle]").forEach((knopf) => {
knopf.addEventListener("click", () => {
if (knopf.dataset.rolle !== "ohne") benutzer.value = knopf.dataset.rolle;
fuellen(knopf.dataset.rolle);
});
});
fuellen("admin");
</script>
</body>
</html>