add configurable club login domains in RMS software
This commit is contained in:
@@ -4,6 +4,7 @@ const SWR_DETAIL_REFRESH_MS = 20000;
|
||||
const DEFAULT_UI_LANGUAGE = "de";
|
||||
const SUPPORTED_UI_LANGUAGES = ["de", "en"];
|
||||
const LANGUAGE_STORAGE_KEY = "rms-lang";
|
||||
const DEFAULT_ALLOWED_LOGIN_DOMAINS = ["arcg.at", "oevsv.at"];
|
||||
|
||||
let activationWatchTimer = null;
|
||||
let activationWatchInFlight = false;
|
||||
@@ -21,7 +22,8 @@ const state = {
|
||||
branding: {
|
||||
logoLightUrl: null,
|
||||
logoDarkUrl: null
|
||||
}
|
||||
},
|
||||
allowedLoginDomains: DEFAULT_ALLOWED_LOGIN_DOMAINS.slice()
|
||||
},
|
||||
accessToken: localStorage.getItem("rms-access-token") || "",
|
||||
refreshToken: localStorage.getItem("rms-refresh-token") || "",
|
||||
@@ -251,6 +253,8 @@ const els = {
|
||||
maintenanceMessageInput: document.getElementById("maintenanceMessageInput"),
|
||||
maintenanceEnableBtn: document.getElementById("maintenanceEnableBtn"),
|
||||
maintenanceDisableBtn: document.getElementById("maintenanceDisableBtn"),
|
||||
loginDomainsInput: document.getElementById("loginDomainsInput"),
|
||||
saveLoginDomainsBtn: document.getElementById("saveLoginDomainsBtn"),
|
||||
logoLightFile: document.getElementById("logoLightFile"),
|
||||
logoDarkFile: document.getElementById("logoDarkFile"),
|
||||
uploadLogoLightBtn: document.getElementById("uploadLogoLightBtn"),
|
||||
@@ -817,6 +821,11 @@ function bindEvents() {
|
||||
await setMaintenanceMode(false);
|
||||
});
|
||||
}
|
||||
if (els.saveLoginDomainsBtn) {
|
||||
els.saveLoginDomainsBtn.addEventListener("click", async () => {
|
||||
await saveLoginDomains();
|
||||
});
|
||||
}
|
||||
if (els.uploadLogoLightBtn) {
|
||||
els.uploadLogoLightBtn.addEventListener("click", async () => {
|
||||
await uploadBrandLogo("light", els.logoLightFile);
|
||||
@@ -902,6 +911,10 @@ async function refreshFrontendOnResume() {
|
||||
async function requestAccess() {
|
||||
clearMessages("auth");
|
||||
const email = els.email.value.trim();
|
||||
if (!isLoginEmailAllowed(email)) {
|
||||
renderMessage(els.authMessage, `Nur Club-Mailadressen (${formatAllowedDomainsHint()}) sind zum Anmelden moeglich.`, true);
|
||||
return;
|
||||
}
|
||||
const method = els.authMethodSelect.value;
|
||||
try {
|
||||
const result = await api("/v1/auth/request-access", {
|
||||
@@ -971,6 +984,30 @@ async function verifyOtpCode() {
|
||||
}
|
||||
}
|
||||
|
||||
function isLoginEmailAllowed(email) {
|
||||
const normalized = String(email || "").trim().toLowerCase();
|
||||
if (!normalized || !normalized.includes("@")) {
|
||||
return false;
|
||||
}
|
||||
const atIndex = normalized.lastIndexOf("@");
|
||||
if (atIndex < 0) {
|
||||
return false;
|
||||
}
|
||||
const domain = normalized.slice(atIndex + 1);
|
||||
return getAllowedLoginDomains().includes(domain);
|
||||
}
|
||||
|
||||
function getAllowedLoginDomains() {
|
||||
const list = state && state.system && Array.isArray(state.system.allowedLoginDomains)
|
||||
? state.system.allowedLoginDomains
|
||||
: [];
|
||||
return list.length > 0 ? list : DEFAULT_ALLOWED_LOGIN_DOMAINS.slice();
|
||||
}
|
||||
|
||||
function formatAllowedDomainsHint() {
|
||||
return getAllowedLoginDomains().map((domain) => `@${domain}`).join(" oder ");
|
||||
}
|
||||
|
||||
async function handleEmailTokenFromUrl() {
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.get("requestApproval") === "1") {
|
||||
@@ -1036,14 +1073,16 @@ async function refreshPublicSystemStatus() {
|
||||
maintenanceMode: Boolean(result.maintenanceMode),
|
||||
maintenanceMessage: result.maintenanceMessage || "",
|
||||
updatedAt: result.updatedAt || null,
|
||||
branding: normalizeBranding(result.branding)
|
||||
branding: normalizeBranding(result.branding),
|
||||
allowedLoginDomains: normalizeLoginDomainList(result.allowedLoginDomains)
|
||||
};
|
||||
} catch {
|
||||
state.system = {
|
||||
maintenanceMode: false,
|
||||
maintenanceMessage: "",
|
||||
updatedAt: null,
|
||||
branding: normalizeBranding(null)
|
||||
branding: normalizeBranding(null),
|
||||
allowedLoginDomains: DEFAULT_ALLOWED_LOGIN_DOMAINS.slice()
|
||||
};
|
||||
}
|
||||
renderMaintenanceBanner();
|
||||
@@ -1165,6 +1204,43 @@ function renderMaintenanceBanner() {
|
||||
if (els.maintenanceMessageInput && !els.maintenanceMessageInput.value) {
|
||||
els.maintenanceMessageInput.value = state.system.maintenanceMessage || "";
|
||||
}
|
||||
if (els.loginDomainsInput && document.activeElement !== els.loginDomainsInput) {
|
||||
els.loginDomainsInput.value = getAllowedLoginDomains().join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLoginDomainList(value) {
|
||||
const list = Array.isArray(value)
|
||||
? value
|
||||
: String(value || "").split(/[\n,;\s]+/);
|
||||
const unique = [];
|
||||
const seen = new Set();
|
||||
for (const entry of list) {
|
||||
const domain = String(entry || "").trim().toLowerCase();
|
||||
if (!domain) continue;
|
||||
if (!/^[a-z0-9.-]+$/.test(domain)) continue;
|
||||
if (!domain.includes(".")) continue;
|
||||
if (seen.has(domain)) continue;
|
||||
seen.add(domain);
|
||||
unique.push(domain);
|
||||
}
|
||||
return unique.length > 0 ? unique : DEFAULT_ALLOWED_LOGIN_DOMAINS.slice();
|
||||
}
|
||||
|
||||
async function saveLoginDomains() {
|
||||
clearMessages("admin");
|
||||
const domains = normalizeLoginDomainList(els.loginDomainsInput ? els.loginDomainsInput.value : "");
|
||||
try {
|
||||
const result = await api("/v1/admin/login-domains", {
|
||||
method: "PUT",
|
||||
body: { domains }
|
||||
});
|
||||
state.system.allowedLoginDomains = normalizeLoginDomainList(result && result.domains ? result.domains : domains);
|
||||
renderMaintenanceBanner();
|
||||
renderMessage(els.adminMessage, `Login-Domains gespeichert (${formatAllowedDomainsHint()}).`, false, true);
|
||||
} catch (error) {
|
||||
renderMessage(els.adminMessage, error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function renderBranding() {
|
||||
|
||||
@@ -407,6 +407,19 @@
|
||||
<button id="maintenanceDisableBtn" type="button" class="ghost-btn">Wartungsmodus deaktivieren</button>
|
||||
</div>
|
||||
|
||||
<hr class="separator" />
|
||||
<div class="section-head">
|
||||
<h3>Login Domains</h3>
|
||||
<span class="pill">E-Mail Richtlinie</span>
|
||||
</div>
|
||||
<label class="field" style="margin-top: 0.6rem">
|
||||
<span>Erlaubte Login-Domains (CSV oder Zeilenumbruch)</span>
|
||||
<textarea id="loginDomainsInput" rows="3" placeholder="arcg.at, oevsv.at"></textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="saveLoginDomainsBtn" type="button" class="ghost-btn">Login Domains speichern</button>
|
||||
</div>
|
||||
|
||||
</article>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ const config = {
|
||||
autoDisableTxBeforeActivation: String(process.env.AUTO_DISABLE_TX_BEFORE_ACTIVATION || "").trim().toLowerCase(),
|
||||
stationMaxUsageSec: Number(process.env.STATION_MAX_USAGE_SEC || 3600),
|
||||
primaryEmailDomain: String(process.env.PRIMARY_EMAIL_DOMAIN || "arcg.at").toLowerCase(),
|
||||
loginAllowedDomains: parseDomainList(process.env.ALLOWED_LOGIN_DOMAINS || "arcg.at,oevsv.at"),
|
||||
publicBaseUrl: String(process.env.PUBLIC_BASE_URL || ""),
|
||||
approverEmails: new Set(
|
||||
String(process.env.APPROVER_EMAILS || "")
|
||||
@@ -162,6 +163,7 @@ const runtime = {
|
||||
logoLightUrl: null,
|
||||
logoDarkUrl: null
|
||||
},
|
||||
loginAllowedDomains: config.loginAllowedDomains,
|
||||
updatedAt: null
|
||||
},
|
||||
pluginState: {
|
||||
@@ -301,6 +303,9 @@ async function routeRequest(req, res) {
|
||||
maintenanceMode: Boolean(runtime.systemState.maintenanceMode),
|
||||
maintenanceMessage: runtime.systemState.maintenanceMessage,
|
||||
branding: runtime.systemState.branding,
|
||||
allowedLoginDomains: Array.isArray(runtime.systemState.loginAllowedDomains)
|
||||
? runtime.systemState.loginAllowedDomains
|
||||
: config.loginAllowedDomains,
|
||||
updatedAt: runtime.systemState.updatedAt
|
||||
});
|
||||
}
|
||||
@@ -849,6 +854,31 @@ async function routeRequest(req, res) {
|
||||
return handleMaintenanceUpdate(res, auth.user, body);
|
||||
}
|
||||
|
||||
if (method === "GET" && url.pathname === "/v1/admin/login-domains") {
|
||||
const auth = requireAuth(req, res);
|
||||
if (!auth) return;
|
||||
if (!hasRole(auth.user, ["admin"])) {
|
||||
return sendError(res, 403, "auth.forbidden", "Nur Admin");
|
||||
}
|
||||
return sendJson(res, 200, {
|
||||
ok: true,
|
||||
domains: Array.isArray(runtime.systemState.loginAllowedDomains)
|
||||
? runtime.systemState.loginAllowedDomains
|
||||
: config.loginAllowedDomains,
|
||||
updatedAt: runtime.systemState.updatedAt
|
||||
});
|
||||
}
|
||||
|
||||
if (method === "PUT" && url.pathname === "/v1/admin/login-domains") {
|
||||
const auth = requireAuth(req, res);
|
||||
if (!auth) return;
|
||||
if (!hasRole(auth.user, ["admin"])) {
|
||||
return sendError(res, 403, "auth.forbidden", "Nur Admin");
|
||||
}
|
||||
const body = await readJsonBody(req);
|
||||
return handleLoginDomainsUpdate(res, auth.user, body);
|
||||
}
|
||||
|
||||
if (method === "PUT" && url.pathname === "/v1/admin/branding/logo") {
|
||||
const auth = requireAuth(req, res);
|
||||
if (!auth) return;
|
||||
@@ -940,6 +970,9 @@ async function handleRequestAccess(req, res, body) {
|
||||
if (!isValidEmail(email)) {
|
||||
return sendError(res, 400, "auth.invalid_email", "Bitte gueltige E-Mail angeben");
|
||||
}
|
||||
if (!isLoginDomainAllowed(email)) {
|
||||
return sendError(res, 403, "auth.email.domain_forbidden", "Nur Club-Mailadressen (@arcg.at oder @oevsv.at) sind zum Anmelden moeglich");
|
||||
}
|
||||
|
||||
let user = runtime.users.find((entry) => entry.email === email);
|
||||
if (runtime.systemState.maintenanceMode) {
|
||||
@@ -1573,6 +1606,22 @@ async function handleMaintenanceUpdate(res, actor, body) {
|
||||
});
|
||||
}
|
||||
|
||||
async function handleLoginDomainsUpdate(res, actor, body) {
|
||||
const domains = normalizeLoginDomainList(body && body.domains);
|
||||
if (domains.length === 0) {
|
||||
return sendError(res, 400, "auth.login_domains.invalid", "Mindestens eine gueltige Domain ist erforderlich");
|
||||
}
|
||||
runtime.systemState.loginAllowedDomains = domains;
|
||||
runtime.systemState.updatedAt = new Date().toISOString();
|
||||
await saveSystemState();
|
||||
await appendAudit("admin.login_domains.update", actor, { domains });
|
||||
return sendJson(res, 200, {
|
||||
ok: true,
|
||||
domains,
|
||||
updatedAt: runtime.systemState.updatedAt
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAdminBrandingLogoUpdate(res, actor, body) {
|
||||
const theme = typeof (body && body.theme) === "string" ? body.theme.trim().toLowerCase() : "";
|
||||
if (theme !== "light" && theme !== "dark") {
|
||||
@@ -1743,6 +1792,7 @@ function parseImageDataUrl(value) {
|
||||
function normalizeSystemState(state) {
|
||||
const input = state && typeof state === "object" ? state : {};
|
||||
const branding = input.branding && typeof input.branding === "object" ? input.branding : {};
|
||||
const loginAllowedDomains = normalizeLoginDomainList(input.loginAllowedDomains);
|
||||
return {
|
||||
maintenanceMode: Boolean(input.maintenanceMode),
|
||||
maintenanceMessage: typeof input.maintenanceMessage === "string" && input.maintenanceMessage.trim()
|
||||
@@ -1752,6 +1802,7 @@ function normalizeSystemState(state) {
|
||||
logoLightUrl: typeof branding.logoLightUrl === "string" && branding.logoLightUrl.trim() ? branding.logoLightUrl : null,
|
||||
logoDarkUrl: typeof branding.logoDarkUrl === "string" && branding.logoDarkUrl.trim() ? branding.logoDarkUrl : null
|
||||
},
|
||||
loginAllowedDomains: loginAllowedDomains.length > 0 ? loginAllowedDomains : config.loginAllowedDomains,
|
||||
updatedAt: input.updatedAt || null
|
||||
};
|
||||
}
|
||||
@@ -2495,10 +2546,41 @@ function domainForEmail(email) {
|
||||
return at === -1 ? "" : email.slice(at + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function parseDomainList(value) {
|
||||
return normalizeLoginDomainList(String(value || ""));
|
||||
}
|
||||
|
||||
function normalizeLoginDomainList(value) {
|
||||
const list = Array.isArray(value)
|
||||
? value
|
||||
: String(value || "")
|
||||
.split(/[\n,;\s]+/);
|
||||
const unique = [];
|
||||
const seen = new Set();
|
||||
for (const entry of list) {
|
||||
const domain = String(entry || "").trim().toLowerCase();
|
||||
if (!domain) continue;
|
||||
if (!/^[a-z0-9.-]+$/.test(domain)) continue;
|
||||
if (!domain.includes(".")) continue;
|
||||
if (seen.has(domain)) continue;
|
||||
seen.add(domain);
|
||||
unique.push(domain);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function isPrimaryDomainEmail(email) {
|
||||
return domainForEmail(email) === config.primaryEmailDomain;
|
||||
}
|
||||
|
||||
function isLoginDomainAllowed(email) {
|
||||
const allowed = Array.isArray(runtime.systemState && runtime.systemState.loginAllowedDomains)
|
||||
? runtime.systemState.loginAllowedDomains
|
||||
: config.loginAllowedDomains;
|
||||
const domain = domainForEmail(email);
|
||||
return allowed.includes(domain);
|
||||
}
|
||||
|
||||
function stationUsageDurationMs() {
|
||||
return Math.max(1, Number(config.stationMaxUsageSec || 3600)) * 1000;
|
||||
}
|
||||
|
||||
@@ -173,12 +173,25 @@ test("plugin-based auth methods support link and OTP flows", async (t) => {
|
||||
assert.ok(adminVerify.accessToken, "admin access token expected");
|
||||
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` };
|
||||
|
||||
let forbiddenDomainError = null;
|
||||
try {
|
||||
await requestJson(baseUrl, "/v1/auth/request-access", {
|
||||
method: "POST",
|
||||
body: { email: "outsider@gmail.com" }
|
||||
});
|
||||
} catch (error) {
|
||||
forbiddenDomainError = error;
|
||||
}
|
||||
assert.ok(forbiddenDomainError, "non-club domains should be rejected immediately");
|
||||
assert.equal(forbiddenDomainError.status, 403);
|
||||
assert.equal(forbiddenDomainError.payload && forbiddenDomainError.payload.error && forbiddenDomainError.payload.error.code, "auth.email.domain_forbidden");
|
||||
|
||||
const guestStart = await requestJson(baseUrl, "/v1/auth/request-access", {
|
||||
method: "POST",
|
||||
body: { email: "guest@example.com" }
|
||||
body: { email: "guest@oevsv.at" }
|
||||
});
|
||||
const outbox2 = await readOutbox(dataDir);
|
||||
const guestVerifyMail = [...outbox2].reverse().find((entry) => entry.to === "guest@example.com");
|
||||
const guestVerifyMail = [...outbox2].reverse().find((entry) => entry.to === "guest@oevsv.at");
|
||||
assert.ok(guestVerifyMail, "guest verify challenge must be written");
|
||||
|
||||
let guestVerify;
|
||||
@@ -187,7 +200,7 @@ test("plugin-based auth methods support link and OTP flows", async (t) => {
|
||||
assert.ok(guestCode, "guest otp code must be parsable");
|
||||
guestVerify = await requestJson(baseUrl, "/v1/auth/verify-email", {
|
||||
method: "POST",
|
||||
body: { email: "guest@example.com", code: guestCode }
|
||||
body: { email: "guest@oevsv.at", code: guestCode }
|
||||
});
|
||||
} else {
|
||||
const guestVerifyToken = extractTokenFromText(guestVerifyMail.text);
|
||||
@@ -200,7 +213,7 @@ test("plugin-based auth methods support link and OTP flows", async (t) => {
|
||||
assert.equal(guestVerify.approved, false, "external domain should not auto-approve");
|
||||
|
||||
const approvals = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders });
|
||||
const pending = (approvals.approvals || []).find((entry) => entry.email === "guest@example.com" && entry.status === "pending");
|
||||
const pending = (approvals.approvals || []).find((entry) => entry.email === "guest@oevsv.at" && entry.status === "pending");
|
||||
assert.ok(pending, "approval request for external user expected");
|
||||
|
||||
await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/approve`, {
|
||||
@@ -210,7 +223,7 @@ test("plugin-based auth methods support link and OTP flows", async (t) => {
|
||||
});
|
||||
|
||||
const users = await requestJson(baseUrl, "/v1/admin/users", { headers: adminHeaders });
|
||||
const guest = (users.users || []).find((entry) => entry.email === "guest@example.com");
|
||||
const guest = (users.users || []).find((entry) => entry.email === "guest@oevsv.at");
|
||||
assert.ok(guest, "guest user must exist");
|
||||
|
||||
await requestJson(baseUrl, `/v1/admin/users/${encodeURIComponent(guest.id)}/auth-methods`, {
|
||||
@@ -224,14 +237,14 @@ test("plugin-based auth methods support link and OTP flows", async (t) => {
|
||||
|
||||
const otpStart = await requestJson(baseUrl, "/v1/auth/request-access", {
|
||||
method: "POST",
|
||||
body: { email: "guest@example.com", method: "otp-email" }
|
||||
body: { email: "guest@oevsv.at", method: "otp-email" }
|
||||
});
|
||||
assert.equal(otpStart.challengeType, "otp", "OTP challenge should be selected");
|
||||
|
||||
const outbox3 = await readOutbox(dataDir);
|
||||
const guestOtpMail = [...outbox3]
|
||||
.reverse()
|
||||
.find((entry) => entry.to === "guest@example.com" && /Code lautet:\s*\d{6}/.test(entry.text));
|
||||
.find((entry) => entry.to === "guest@oevsv.at" && /Code lautet:\s*\d{6}/.test(entry.text));
|
||||
assert.ok(guestOtpMail, "guest OTP mail must be written");
|
||||
const otpMatch = /Code lautet:\s*(\d{6})/.exec(guestOtpMail.text);
|
||||
assert.ok(otpMatch, "otp code must be parsable");
|
||||
@@ -239,7 +252,7 @@ test("plugin-based auth methods support link and OTP flows", async (t) => {
|
||||
const otpVerify = await requestJson(baseUrl, "/v1/auth/verify-email", {
|
||||
method: "POST",
|
||||
body: {
|
||||
email: "guest@example.com",
|
||||
email: "guest@oevsv.at",
|
||||
code: otpMatch[1]
|
||||
}
|
||||
});
|
||||
@@ -346,6 +359,80 @@ test("maintenance mode keeps admin active and blocks non-admin login", async (t)
|
||||
}
|
||||
});
|
||||
|
||||
test("admin can configure allowed login domains", async (t) => {
|
||||
const rootDir = path.resolve(__dirname, "..");
|
||||
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-auth-domains-"));
|
||||
const port = randomPort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
|
||||
const server = spawn(process.execPath, ["server/index.js"], {
|
||||
cwd: rootDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
DATA_DIR: dataDir,
|
||||
ADMIN_EMAILS: "admin@arcg.at",
|
||||
PRIMARY_EMAIL_DOMAIN: "arcg.at",
|
||||
PUBLIC_BASE_URL: baseUrl
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
});
|
||||
|
||||
let serverStdErr = "";
|
||||
server.stderr.on("data", (chunk) => {
|
||||
serverStdErr += String(chunk);
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
if (!server.killed) {
|
||||
server.kill("SIGTERM");
|
||||
}
|
||||
await fs.rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
await waitForServer(baseUrl);
|
||||
|
||||
const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at");
|
||||
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` };
|
||||
|
||||
const initial = await requestJson(baseUrl, "/v1/admin/login-domains", { headers: adminHeaders });
|
||||
assert.equal(initial.ok, true);
|
||||
assert.ok(Array.isArray(initial.domains));
|
||||
assert.ok(initial.domains.includes("arcg.at"));
|
||||
assert.ok(initial.domains.includes("oevsv.at"));
|
||||
|
||||
const update = await requestJson(baseUrl, "/v1/admin/login-domains", {
|
||||
method: "PUT",
|
||||
headers: adminHeaders,
|
||||
body: { domains: ["arcg.at"] }
|
||||
});
|
||||
assert.equal(update.ok, true);
|
||||
assert.deepEqual(update.domains, ["arcg.at"]);
|
||||
|
||||
let forbidden = null;
|
||||
try {
|
||||
await requestJson(baseUrl, "/v1/auth/request-access", {
|
||||
method: "POST",
|
||||
body: { email: "guest@oevsv.at" }
|
||||
});
|
||||
} catch (error) {
|
||||
forbidden = error;
|
||||
}
|
||||
assert.ok(forbidden, "oevsv domain should be blocked after policy update");
|
||||
assert.equal(forbidden.status, 403);
|
||||
assert.equal(forbidden.payload && forbidden.payload.error && forbidden.payload.error.code, "auth.email.domain_forbidden");
|
||||
|
||||
const allowed = await requestJson(baseUrl, "/v1/auth/request-access", {
|
||||
method: "POST",
|
||||
body: { email: "member@arcg.at" }
|
||||
});
|
||||
assert.equal(allowed.ok, true);
|
||||
|
||||
if (server.exitCode !== null && server.exitCode !== 0) {
|
||||
throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("approver role can process external approvals without admin role", async (t) => {
|
||||
const rootDir = path.resolve(__dirname, "..");
|
||||
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-approver-test-"));
|
||||
@@ -417,11 +504,11 @@ test("approver role can process external approvals without admin role", async (t
|
||||
body: {}
|
||||
});
|
||||
|
||||
const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, "remote@outside.example");
|
||||
const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, "remote@oevsv.at");
|
||||
assert.equal(externalVerify.approved, false, "external user should require approval");
|
||||
|
||||
const approvalsForApprover = await requestJson(baseUrl, "/v1/approvals", { headers: approverHeaders });
|
||||
const pending = (approvalsForApprover.approvals || []).find((entry) => entry.email === "remote@outside.example" && entry.status === "pending");
|
||||
const pending = (approvalsForApprover.approvals || []).find((entry) => entry.email === "remote@oevsv.at" && entry.status === "pending");
|
||||
assert.ok(pending, "approver should see pending external request");
|
||||
|
||||
await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/approve`, {
|
||||
@@ -430,7 +517,7 @@ test("approver role can process external approvals without admin role", async (t
|
||||
body: {}
|
||||
});
|
||||
|
||||
const externalLogin = await requestAccessAndVerify(baseUrl, dataDir, "remote@outside.example");
|
||||
const externalLogin = await requestAccessAndVerify(baseUrl, dataDir, "remote@oevsv.at");
|
||||
assert.ok(externalLogin.accessToken, "external user should be able to login after approver decision");
|
||||
|
||||
const operatorVerify = await requestAccessAndVerify(baseUrl, dataDir, "operator@arcg.at");
|
||||
@@ -482,7 +569,7 @@ test("rejected external user can request approval again via request-approval flo
|
||||
const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at");
|
||||
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` };
|
||||
|
||||
const externalEmail = "blocked@external.example";
|
||||
const externalEmail = "blocked@oevsv.at";
|
||||
const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail);
|
||||
assert.equal(externalVerify.approved, false, "external user should require approval");
|
||||
|
||||
@@ -564,7 +651,7 @@ test("auth error codes remain stable for denied/not-approved/maintenance", async
|
||||
const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at");
|
||||
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` };
|
||||
|
||||
const externalEmail = "codes@external.example";
|
||||
const externalEmail = "codes@oevsv.at";
|
||||
const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail);
|
||||
assert.equal(externalVerify.approved, false);
|
||||
|
||||
@@ -1013,7 +1100,7 @@ test("approvals list exposes account status and supports repeated decisions", as
|
||||
const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at");
|
||||
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` };
|
||||
|
||||
const externalEmail = "statuscheck@outside.example";
|
||||
const externalEmail = "statuscheck@oevsv.at";
|
||||
const verify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail);
|
||||
assert.equal(verify.approved, false);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user