const test = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path"); const { spawn } = require("node:child_process"); function randomPort() { return 18080 + Math.floor(Math.random() * 1000); } async function sleep(ms) { await new Promise((resolve) => setTimeout(resolve, ms)); } async function waitForServer(baseUrl, timeoutMs = 15000) { const started = Date.now(); while (Date.now() - started < timeoutMs) { try { const res = await fetch(`${baseUrl}/api/health`); if (res.ok) { return; } } catch { // retry } await sleep(200); } throw new Error("server did not become ready in time"); } async function waitForCondition(checkFn, timeoutMs = 15000, intervalMs = 150) { const started = Date.now(); while (Date.now() - started < timeoutMs) { const value = await checkFn(); if (value) { return value; } await sleep(intervalMs); } throw new Error("condition not met in time"); } async function requestJson(baseUrl, route, options = {}) { const headers = { "Content-Type": "application/json", ...(options.headers || {}) }; const response = await fetch(`${baseUrl}${route}`, { method: options.method || "GET", headers, body: options.body ? JSON.stringify(options.body) : undefined }); const payload = await response.json().catch(() => ({})); if (!response.ok) { const message = payload && payload.error && payload.error.message ? payload.error.message : `HTTP ${response.status}`; const err = new Error(message); err.status = response.status; err.payload = payload; throw err; } return payload; } async function readOutbox(dataDir) { const outboxPath = path.join(dataDir, "mail-outbox.log"); const raw = await fs.readFile(outboxPath, "utf8"); return raw .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .map((line) => JSON.parse(line)); } function extractTokenFromText(text) { const loginMatch = /loginToken=([^\s]+)/.exec(text); if (loginMatch) return decodeURIComponent(loginMatch[1]); const verifyMatch = /verifyToken=([^\s]+)/.exec(text); if (verifyMatch) return decodeURIComponent(verifyMatch[1]); return ""; } function extractOtpFromText(text) { const match = /Code lautet:\s*(\d{6})/.exec(text); return match ? match[1] : ""; } async function requestAccessAndVerify(baseUrl, dataDir, email, method) { const start = await requestJson(baseUrl, "/v1/auth/request-access", { method: "POST", body: method ? { email, method } : { email } }); const outbox = await readOutbox(dataDir); const mail = [...outbox].reverse().find((entry) => entry.to === email); assert.ok(mail, `challenge mail for ${email} must exist`); if (start.challengeType === "otp") { const code = extractOtpFromText(mail.text); assert.ok(code, `otp code for ${email} must be parsable`); return requestJson(baseUrl, "/v1/auth/verify-email", { method: "POST", body: { email, code } }); } const token = extractTokenFromText(mail.text); assert.ok(token, `token for ${email} must be parsable`); return requestJson(baseUrl, "/v1/auth/verify-email", { method: "POST", body: { token } }); } test("plugin-based auth methods support link and OTP flows", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-auth-test-")); 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 methods = await requestJson(baseUrl, "/v1/public/auth-methods"); const methodIds = new Set((methods.methods || []).map((entry) => entry.id)); assert.ok(methodIds.has("smtp-link"), "smtp-link method must be available"); assert.ok(methodIds.has("otp-email"), "otp-email method must be available"); const adminStart = await requestJson(baseUrl, "/v1/auth/request-access", { method: "POST", body: { email: "admin@arcg.at" } }); const outbox1 = await readOutbox(dataDir); const adminLastMail = [...outbox1].reverse().find((entry) => entry.to === "admin@arcg.at"); assert.ok(adminLastMail, "admin challenge must be written"); let adminVerify; if (adminStart.challengeType === "otp") { const code = extractOtpFromText(adminLastMail.text); assert.ok(code, "admin otp code must be parsable"); adminVerify = await requestJson(baseUrl, "/v1/auth/verify-email", { method: "POST", body: { email: "admin@arcg.at", code } }); } else { const adminToken = extractTokenFromText(adminLastMail.text); assert.ok(adminToken, "admin verify token must be parsable"); adminVerify = await requestJson(baseUrl, "/v1/auth/verify-email", { method: "POST", body: { token: adminToken } }); } 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@oevsv.at" } }); const outbox2 = await readOutbox(dataDir); const guestVerifyMail = [...outbox2].reverse().find((entry) => entry.to === "guest@oevsv.at"); assert.ok(guestVerifyMail, "guest verify challenge must be written"); let guestVerify; if (guestStart.challengeType === "otp") { const guestCode = extractOtpFromText(guestVerifyMail.text); assert.ok(guestCode, "guest otp code must be parsable"); guestVerify = await requestJson(baseUrl, "/v1/auth/verify-email", { method: "POST", body: { email: "guest@oevsv.at", code: guestCode } }); } else { const guestVerifyToken = extractTokenFromText(guestVerifyMail.text); assert.ok(guestVerifyToken, "guest verify token must be parsable"); guestVerify = await requestJson(baseUrl, "/v1/auth/verify-email", { method: "POST", body: { token: guestVerifyToken } }); } 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@oevsv.at" && entry.status === "pending"); assert.ok(pending, "approval request for external user expected"); await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/approve`, { method: "POST", headers: adminHeaders, body: {} }); const users = await requestJson(baseUrl, "/v1/admin/users", { headers: adminHeaders }); 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`, { method: "PUT", headers: adminHeaders, body: { enabledMethods: ["otp-email"], primaryMethod: "otp-email" } }); const otpStart = await requestJson(baseUrl, "/v1/auth/request-access", { method: "POST", 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@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"); const otpVerify = await requestJson(baseUrl, "/v1/auth/verify-email", { method: "POST", body: { email: "guest@oevsv.at", code: otpMatch[1] } }); assert.ok(otpVerify.accessToken, "guest OTP login should return token"); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); test("maintenance mode keeps admin active and blocks non-admin login", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-maintenance-test-")); 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"); assert.ok(adminVerify.accessToken, "admin token expected"); const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` }; const operatorVerify = await requestAccessAndVerify(baseUrl, dataDir, "operator@arcg.at"); assert.ok(operatorVerify.accessToken, "operator token expected"); const enabled = await requestJson(baseUrl, "/v1/admin/maintenance", { method: "PUT", headers: adminHeaders, body: { enabled: true, message: "Geplante Wartung" } }); assert.equal(enabled.maintenanceMode, true); const publicSystem = await requestJson(baseUrl, "/v1/public/system"); assert.equal(publicSystem.maintenanceMode, true); const adminStillLoggedIn = await requestJson(baseUrl, "/v1/me", { headers: adminHeaders }); assert.equal(adminStillLoggedIn.user.email, "admin@arcg.at"); await assert.rejects( requestJson(baseUrl, "/v1/me", { headers: { Authorization: `Bearer ${operatorVerify.accessToken}` } }), (error) => error && (error.status === 401 || error.status === 503) ); await assert.rejects( requestJson(baseUrl, "/v1/auth/refresh", { method: "POST", body: { refreshToken: operatorVerify.refreshToken } }), (error) => error && (error.status === 401 || error.status === 503) ); await assert.rejects( requestJson(baseUrl, "/v1/auth/request-access", { method: "POST", body: { email: "newuser@arcg.at" } }), (error) => error && error.status === 503 ); const adminRequestDuringMaintenance = await requestJson(baseUrl, "/v1/auth/request-access", { method: "POST", body: { email: "admin@arcg.at" } }); assert.ok(adminRequestDuringMaintenance.ok); const adminAgain = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); const adminAgainHeaders = { Authorization: `Bearer ${adminAgain.accessToken}` }; const disabled = await requestJson(baseUrl, "/v1/admin/maintenance", { method: "PUT", headers: adminAgainHeaders, body: { enabled: false } }); assert.equal(disabled.maintenanceMode, false); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); 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-")); 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 approverVerify = await requestAccessAndVerify(baseUrl, dataDir, "approver@arcg.at"); assert.ok(approverVerify.accessToken, "approver user token expected"); const approverUserList = await requestJson(baseUrl, "/v1/admin/users", { headers: adminHeaders }); const approverUser = (approverUserList.users || []).find((entry) => entry.email === "approver@arcg.at"); assert.ok(approverUser, "approver user record expected"); await requestJson(baseUrl, `/v1/admin/users/${encodeURIComponent(approverUser.id)}/role`, { method: "PUT", headers: adminHeaders, body: { role: "approver" } }); const approverLogin = await requestAccessAndVerify(baseUrl, dataDir, "approver@arcg.at"); const approverHeaders = { Authorization: `Bearer ${approverLogin.accessToken}` }; const controls = await requestJson(baseUrl, "/v1/ui/controls", { headers: approverHeaders }); const stationMain = (controls.controls || []).find((entry) => entry.controlId === "station-main"); assert.ok(stationMain, "approver should see station control"); await requestJson(baseUrl, "/v1/station/activation-jobs", { method: "POST", headers: approverHeaders, body: {} }); await waitForCondition(async () => { const status = await requestJson(baseUrl, "/v1/station/status", { headers: approverHeaders }); return status.isInUse ? status : null; }, 20000, 250); await requestJson(baseUrl, "/v1/station/release", { method: "POST", headers: approverHeaders, body: {} }); 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@oevsv.at" && entry.status === "pending"); assert.ok(pending, "approver should see pending external request"); await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/approve`, { method: "POST", headers: approverHeaders, body: {} }); 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"); await assert.rejects( requestJson(baseUrl, "/v1/approvals", { headers: { Authorization: `Bearer ${operatorVerify.accessToken}` } }), (error) => error && error.status === 403 ); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); test("rejected external user can request approval again via request-approval flow", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-reject-test-")); 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 externalEmail = "blocked@oevsv.at"; const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail); assert.equal(externalVerify.approved, false, "external user should require approval"); const approvals = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders }); const pending = (approvals.approvals || []).find((entry) => entry.email === externalEmail && entry.status === "pending"); assert.ok(pending, "pending approval for external user expected"); await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/reject`, { method: "POST", headers: adminHeaders, body: {} }); let deniedError = null; try { await requestJson(baseUrl, "/v1/auth/request-access", { method: "POST", body: { email: externalEmail } }); } catch (error) { deniedError = error; } assert.ok(deniedError, "denied user request-access should fail"); assert.equal(deniedError.status, 403); assert.equal(deniedError.payload.error.code, "auth.access_denied"); assert.ok( deniedError.payload.error.details && deniedError.payload.error.details.requestApprovalUrl, "requestApprovalUrl should be included for denied users" ); const requestApproval = await requestJson(baseUrl, "/v1/auth/request-approval", { method: "POST", body: { email: externalEmail } }); assert.equal(requestApproval.ok, true); const approvalsAfter = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders }); const reopened = (approvalsAfter.approvals || []).find((entry) => entry.email === externalEmail && entry.status === "pending"); assert.ok(reopened, "approval should be reopened after request-approval"); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); test("auth error codes remain stable for denied/not-approved/maintenance", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-errors-test-")); 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 externalEmail = "codes@oevsv.at"; const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail); assert.equal(externalVerify.approved, false); const approvals = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders }); const pending = (approvals.approvals || []).find((entry) => entry.email === externalEmail && entry.status === "pending"); assert.ok(pending); await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/approve`, { method: "POST", headers: adminHeaders, body: {} }); const externalLogin = await requestAccessAndVerify(baseUrl, dataDir, externalEmail); assert.ok(externalLogin.refreshToken); await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/reject`, { method: "POST", headers: adminHeaders, body: {} }); let notApprovedError = null; try { await requestJson(baseUrl, "/v1/auth/refresh", { method: "POST", body: { refreshToken: externalLogin.refreshToken } }); } catch (error) { notApprovedError = error; } assert.ok(notApprovedError); assert.equal(notApprovedError.status, 403); assert.equal(notApprovedError.payload.error.code, "auth.not_approved"); let deniedError = null; try { await requestJson(baseUrl, "/v1/auth/request-access", { method: "POST", body: { email: externalEmail } }); } catch (error) { deniedError = error; } assert.ok(deniedError); assert.equal(deniedError.status, 403); assert.equal(deniedError.payload.error.code, "auth.access_denied"); assert.ok(deniedError.payload.error.details.requestApprovalUrl); await requestJson(baseUrl, "/v1/admin/maintenance", { method: "PUT", headers: adminHeaders, body: { enabled: true, message: "Wartungstest" } }); let maintenanceError = null; try { await requestJson(baseUrl, "/v1/auth/request-access", { method: "POST", body: { email: "newperson@arcg.at" } }); } catch (error) { maintenanceError = error; } assert.ok(maintenanceError); assert.equal(maintenanceError.status, 503); assert.equal(maintenanceError.payload.error.code, "auth.maintenance"); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); test("user can update own preferred auth method via /v1/me/auth-method", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-self-auth-method-test-")); 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 methods = await requestJson(baseUrl, "/v1/public/auth-methods"); const methodIds = new Set((methods.methods || []).map((entry) => entry.id)); assert.ok(methodIds.has("smtp-link")); assert.ok(methodIds.has("otp-email")); const userVerify = await requestAccessAndVerify(baseUrl, dataDir, "self@arcg.at"); assert.ok(userVerify.accessToken); const userHeaders = { Authorization: `Bearer ${userVerify.accessToken}` }; const setOtp = await requestJson(baseUrl, "/v1/me/auth-method", { method: "PUT", headers: userHeaders, body: { primaryMethod: "otp-email" } }); assert.equal(setOtp.user.primaryAuthMethod, "otp-email"); const meAfterOtp = await requestJson(baseUrl, "/v1/me", { headers: userHeaders }); assert.equal(meAfterOtp.user.primaryAuthMethod, "otp-email"); let invalidError = null; try { await requestJson(baseUrl, "/v1/me/auth-method", { method: "PUT", headers: userHeaders, body: { primaryMethod: "not-a-method" } }); } catch (error) { invalidError = error; } assert.ok(invalidError); assert.equal(invalidError.status, 400); assert.equal(invalidError.payload.error.code, "user.auth_methods.invalid"); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); test("approver can view users but cannot change roles or auth methods", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-approver-readonly-test-")); 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 approverVerify = await requestAccessAndVerify(baseUrl, dataDir, "readonly@arcg.at"); const users = await requestJson(baseUrl, "/v1/admin/users", { headers: adminHeaders }); const approverUser = (users.users || []).find((entry) => entry.email === "readonly@arcg.at"); assert.ok(approverUser); await requestJson(baseUrl, `/v1/admin/users/${encodeURIComponent(approverUser.id)}/role`, { method: "PUT", headers: adminHeaders, body: { role: "approver" } }); const approverLogin = await requestAccessAndVerify(baseUrl, dataDir, "readonly@arcg.at"); const approverHeaders = { Authorization: `Bearer ${approverLogin.accessToken}` }; const visibleUsers = await requestJson(baseUrl, "/v1/admin/users", { headers: approverHeaders }); assert.ok(Array.isArray(visibleUsers.users)); assert.ok(visibleUsers.users.length >= 2); let roleMutationError = null; try { await requestJson(baseUrl, `/v1/admin/users/${encodeURIComponent(approverUser.id)}/role`, { method: "PUT", headers: approverHeaders, body: { role: "admin" } }); } catch (error) { roleMutationError = error; } assert.ok(roleMutationError); assert.equal(roleMutationError.status, 403); let authMethodMutationError = null; try { await requestJson(baseUrl, `/v1/admin/users/${encodeURIComponent(approverUser.id)}/auth-methods`, { method: "PUT", headers: approverHeaders, body: { enabledMethods: ["smtp-link"], primaryMethod: "smtp-link" } }); } catch (error) { authMethodMutationError = error; } assert.ok(authMethodMutationError); assert.equal(authMethodMutationError.status, 403); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); test("self preferred auth method must be enabled for that user", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-self-auth-restrict-test-")); 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 userVerify = await requestAccessAndVerify(baseUrl, dataDir, "limit@arcg.at"); const userHeaders = { Authorization: `Bearer ${userVerify.accessToken}` }; const users = await requestJson(baseUrl, "/v1/admin/users", { headers: adminHeaders }); const limitedUser = (users.users || []).find((entry) => entry.email === "limit@arcg.at"); assert.ok(limitedUser); await requestJson(baseUrl, `/v1/admin/users/${encodeURIComponent(limitedUser.id)}/auth-methods`, { method: "PUT", headers: adminHeaders, body: { enabledMethods: ["smtp-link"], primaryMethod: "smtp-link" } }); let disallowedError = null; try { await requestJson(baseUrl, "/v1/me/auth-method", { method: "PUT", headers: userHeaders, body: { primaryMethod: "otp-email" } }); } catch (error) { disallowedError = error; } assert.ok(disallowedError); assert.equal(disallowedError.status, 400); assert.equal(disallowedError.payload.error.code, "user.auth_methods.invalid"); const allowed = await requestJson(baseUrl, "/v1/me/auth-method", { method: "PUT", headers: userHeaders, body: { primaryMethod: "smtp-link" } }); assert.equal(allowed.user.primaryAuthMethod, "smtp-link"); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); test("new users default to smtp-link and outbox uses smtp relay plugin", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-smtp-default-test-")); 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 start = await requestJson(baseUrl, "/v1/auth/request-access", { method: "POST", body: { email: "default@arcg.at" } }); assert.equal(start.challengeType, "link", "default challenge should be link"); assert.equal(start.method, "smtp-link", "default method should prefer smtp-link"); const outbox = await readOutbox(dataDir); const mail = [...outbox].reverse().find((entry) => entry.to === "default@arcg.at"); assert.ok(mail, "mail entry expected for default user"); assert.equal(mail.via, "rms.auth.smtp_relay", "smtp relay plugin should dispatch messages"); assert.match(String(mail.text || ""), /verifyToken=/, "verify token link should be present"); const token = extractTokenFromText(mail.text); const verify = await requestJson(baseUrl, "/v1/auth/verify-email", { method: "POST", body: { token } }); assert.equal(verify.user.primaryAuthMethod, "smtp-link", "new user primary method should be smtp-link"); assert.ok(Array.isArray(verify.user.enabledAuthMethods)); assert.ok(verify.user.enabledAuthMethods.includes("smtp-link")); assert.ok(verify.user.enabledAuthMethods.includes("otp-email")); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); test("public auth methods expose smtp-link first with 'per Mail' label", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-public-methods-test-")); 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 methods = await requestJson(baseUrl, "/v1/public/auth-methods"); assert.ok(Array.isArray(methods.methods)); assert.ok(methods.methods.length >= 2); assert.equal(methods.methods[0].id, "smtp-link"); assert.equal(methods.methods[0].label, "per Mail"); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); test("approvals list exposes account status and supports repeated decisions", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-approvals-view-test-")); 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 externalEmail = "statuscheck@oevsv.at"; const verify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail); assert.equal(verify.approved, false); const list1 = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders }); const pending = (list1.approvals || []).find((entry) => entry.email === externalEmail); assert.ok(pending); assert.equal(pending.userStatus, "pending_approval"); assert.equal(typeof pending.userRole, "string"); await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/approve`, { method: "POST", headers: adminHeaders, body: {} }); const list2 = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders }); const approved = (list2.approvals || []).find((entry) => entry.id === pending.id); assert.ok(approved); assert.equal(approved.status, "approved"); assert.equal(approved.userStatus, "active"); await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/reject`, { method: "POST", headers: adminHeaders, body: {} }); const list3 = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders }); const rejected = (list3.approvals || []).find((entry) => entry.id === pending.id); assert.ok(rejected); assert.equal(rejected.status, "rejected"); assert.equal(rejected.userStatus, "denied"); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); test("admin can delete individual approval and activity entries", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-delete-entries-test-")); 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 externalEmail = "deleteme@oevsv.at"; const verify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail); assert.equal(verify.approved, false); const approvalsBefore = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders }); const pending = (approvalsBefore.approvals || []).find((entry) => entry.email === externalEmail); assert.ok(pending, "approval entry should exist before delete"); const deleteApproval = await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}`, { method: "DELETE", headers: adminHeaders }); assert.equal(deleteApproval.ok, true); assert.equal(deleteApproval.deletedId, pending.id); const approvalsAfter = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders }); assert.equal((approvalsAfter.approvals || []).some((entry) => entry.id === pending.id), false); const activityBefore = await requestJson(baseUrl, "/v1/activity-log?limit=300", { headers: adminHeaders }); assert.ok(Array.isArray(activityBefore.entries)); const targetActivity = activityBefore.entries.find((entry) => entry && entry.id && entry.action === "auth.request_access"); assert.ok(targetActivity, "activity entry with id should exist before delete"); const deleteActivity = await requestJson(baseUrl, `/v1/activity-log/${encodeURIComponent(targetActivity.id)}`, { method: "DELETE", headers: adminHeaders }); assert.equal(deleteActivity.ok, true); assert.equal(deleteActivity.deletedId, targetActivity.id); const activityAfter = await requestJson(baseUrl, "/v1/activity-log?limit=300", { headers: adminHeaders }); assert.equal((activityAfter.entries || []).some((entry) => entry.id === targetActivity.id), false); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); test("station usage is auto-ended after configured max usage time", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-station-timeout-test-")); 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, STATION_MAX_USAGE_SEC: "2" }, 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 headers = { Authorization: `Bearer ${adminVerify.accessToken}` }; await requestJson(baseUrl, "/v1/station/activation-jobs", { method: "POST", headers, body: {} }); const activeStatus = await waitForCondition(async () => { const status = await requestJson(baseUrl, "/v1/station/status", { headers }); return status.isInUse ? status : null; }, 20000, 250); assert.ok(activeStatus.endsAt, "activation should set end time"); const releasedStatus = await waitForCondition(async () => { const status = await requestJson(baseUrl, "/v1/station/status", { headers }); return status.isInUse ? null : status; }, 15000, 300); assert.equal(releasedStatus.isInUse, false); assert.equal(releasedStatus.activeByEmail, null); assert.equal(releasedStatus.endsAt, null); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); test("activity log endpoint lists link requests and station operations", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-activity-log-test-")); 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 headers = { Authorization: `Bearer ${adminVerify.accessToken}` }; const approverVerify = await requestAccessAndVerify(baseUrl, dataDir, "logviewer@arcg.at"); const users = await requestJson(baseUrl, "/v1/admin/users", { headers }); const approverUser = (users.users || []).find((entry) => entry.email === "logviewer@arcg.at"); assert.ok(approverUser); await requestJson(baseUrl, `/v1/admin/users/${encodeURIComponent(approverUser.id)}/role`, { method: "PUT", headers, body: { role: "approver" } }); const approverLogin = await requestAccessAndVerify(baseUrl, dataDir, "logviewer@arcg.at"); const approverHeaders = { Authorization: `Bearer ${approverLogin.accessToken}` }; await requestJson(baseUrl, "/v1/auth/request-access", { method: "POST", body: { email: "viewer@arcg.at" } }); await requestJson(baseUrl, "/v1/station/activation-jobs", { method: "POST", headers, body: {} }); await waitForCondition(async () => { const status = await requestJson(baseUrl, "/v1/station/status", { headers }); return status.isInUse ? true : null; }, 20000, 250); await requestJson(baseUrl, "/v1/station/release", { method: "POST", headers, body: {} }); const log = await requestJson(baseUrl, "/v1/activity-log?limit=200", { headers }); assert.ok(Array.isArray(log.entries)); const actions = new Set(log.entries.map((entry) => entry.action)); assert.ok(actions.has("auth.request_access")); assert.ok(actions.has("station.activate.start")); assert.ok(actions.has("station.activate.done")); assert.ok(actions.has("station.deactivate")); const approverLog = await requestJson(baseUrl, "/v1/activity-log?limit=50", { headers: approverHeaders }); assert.ok(Array.isArray(approverLog.entries)); assert.ok(approverLog.entries.length > 0); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); test("admin can upload theme logos and public system exposes branding URLs", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-branding-upload-test-")); 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 headers = { Authorization: `Bearer ${adminVerify.accessToken}` }; const tinyPng = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO6X3mQAAAAASUVORK5CYII="; await requestJson(baseUrl, "/v1/admin/branding/logo", { method: "PUT", headers, body: { theme: "light", dataUrl: tinyPng, fileName: "light.png" } }); await requestJson(baseUrl, "/v1/admin/branding/logo", { method: "PUT", headers, body: { theme: "dark", dataUrl: tinyPng, fileName: "dark.png" } }); const system = await requestJson(baseUrl, "/v1/public/system"); assert.ok(system.branding); assert.equal(system.branding.logoLightUrl, "/uploads/logo-light.png"); assert.equal(system.branding.logoDarkUrl, "/uploads/logo-dark.png"); const lightRes = await fetch(`${baseUrl}${system.branding.logoLightUrl}`); assert.equal(lightRes.status, 200); const darkRes = await fetch(`${baseUrl}${system.branding.logoDarkUrl}`); assert.equal(darkRes.status, 200); await requestJson(baseUrl, "/v1/admin/branding/logo?theme=light", { method: "DELETE", headers }); const systemAfterDelete = await requestJson(baseUrl, "/v1/public/system"); assert.equal(systemAfterDelete.branding.logoLightUrl, null); assert.equal(systemAfterDelete.branding.logoDarkUrl, "/uploads/logo-dark.png"); const lightResAfterDelete = await fetch(`${baseUrl}/uploads/logo-light.png`); assert.equal(lightResAfterDelete.status, 404); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); test("swr report endpoint returns band data and supports manual run trigger", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-swr-report-test-")); 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 headers = { Authorization: `Bearer ${adminVerify.accessToken}` }; const report = await requestJson(baseUrl, "/v1/swr/report", { headers }); assert.ok(Array.isArray(report.bands)); assert.ok(report.bands.length >= 8); assert.ok(report.bands.some((entry) => entry.band === "20m")); const run = await requestJson(baseUrl, "/v1/swr/run-check", { method: "POST", headers, body: {} }); assert.equal(run.ok, true); assert.ok(run.report); const staleLegacyPath = path.join(dataDir, "vswr", "legacy-report.json"); await fs.mkdir(path.dirname(staleLegacyPath), { recursive: true }); await fs.writeFile(staleLegacyPath, JSON.stringify({ generatedAt: "2000-01-01T00:00:00.000Z", overallStatus: "FAILED", bands: [ { band: "20m", status: "FAILED", error: "stale legacy" } ] }, null, 2)); const refreshedReport = await requestJson(baseUrl, "/v1/swr/report", { headers }); assert.equal(refreshedReport.generatedAt, run.report.generatedAt); const refreshed20m = Array.isArray(refreshedReport.bands) ? refreshedReport.bands.find((entry) => entry && entry.band === "20m") : null; assert.ok(refreshed20m); assert.notEqual(String(refreshed20m.status || "").toUpperCase(), "FAILED"); await requestJson(baseUrl, "/v1/station/activation-jobs", { method: "POST", headers, body: {} }); await waitForCondition(async () => { const status = await requestJson(baseUrl, "/v1/station/status", { headers }); return status.isInUse ? status : null; }, 20000, 250); await assert.rejects( requestJson(baseUrl, "/v1/swr/run-check", { method: "POST", headers, body: {} }), (error) => error && error.status === 409 && error.payload && error.payload.error && error.payload.error.code === "vswr.unsafe_station_active" ); await requestJson(baseUrl, "/v1/station/release", { method: "POST", headers, body: {} }); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); test("tx-active lock blocks any switching and swr run", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-tx-lock-test-")); const port = randomPort(); const baseUrl = `http://127.0.0.1:${port}`; const txStatePath = path.join(dataDir, "tx-state.json"); await fs.writeFile(txStatePath, JSON.stringify({ txActive: true, updatedAt: new Date().toISOString(), source: "test" })); const server = spawn(process.execPath, ["server/index.js"], { cwd: rootDir, env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, TX_STATE_PATH: txStatePath, 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 headers = { Authorization: `Bearer ${adminVerify.accessToken}` }; await assert.rejects( requestJson(baseUrl, "/v1/swr/run-check", { method: "POST", headers, body: {} }), (error) => error && error.status === 409 && error.payload && error.payload.error && error.payload.error.code === "tx.switch_locked" ); await assert.rejects( requestJson(baseUrl, "/v1/station/activation-jobs", { method: "POST", headers, body: {} }), (error) => error && error.status === 409 && error.payload && error.payload.error && error.payload.error.code === "tx.switch_locked" ); await assert.rejects( requestJson(baseUrl, "/v1/ui/controls/rfroute-main/actions/setRoute", { method: "POST", headers, body: { input: { route: "beam" } } }), (error) => error && error.status === 409 && error.payload && error.payload.error && error.payload.error.code === "tx.switch_locked" ); await fs.writeFile(txStatePath, JSON.stringify({ txActive: false, updatedAt: new Date().toISOString(), source: "test" })); const unlockRun = await requestJson(baseUrl, "/v1/swr/run-check", { method: "POST", headers, body: {} }); assert.equal(unlockRun.ok, true); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); test("openwebrx session is owner-bound and release disables tx first", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-openwebrx-test-")); const port = randomPort(); const baseUrl = `http://127.0.0.1:${port}`; const txStatePath = path.join(dataDir, "tx-state.json"); const server = spawn(process.execPath, ["server/index.js"], { cwd: rootDir, env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, TX_STATE_PATH: txStatePath, ADMIN_EMAILS: "admin@arcg.at", PRIMARY_EMAIL_DOMAIN: "arcg.at", PUBLIC_BASE_URL: baseUrl, OPENWEBRX_PATH: "/openwebrx/" }, 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 headers = { Authorization: `Bearer ${adminVerify.accessToken}` }; await requestJson(baseUrl, "/v1/station/activation-jobs", { method: "POST", headers, body: {} }); await waitForCondition(async () => { const status = await requestJson(baseUrl, "/v1/station/status", { headers }); return status.isInUse ? status : null; }, 20000, 250); const session = await requestJson(baseUrl, "/v1/openwebrx/session", { method: "POST", headers, body: {} }); assert.equal(session.ok, true); assert.ok(session.session.ticket); assert.match(String(session.session.iframeUrl || ""), /[?&]ticket=/); const authOk = await fetch(`${baseUrl}/v1/openwebrx/authorize?ticket=${encodeURIComponent(session.session.ticket)}`); assert.equal(authOk.status, 200); const authWithoutTicket = await fetch(`${baseUrl}/v1/openwebrx/authorize`); assert.equal(authWithoutTicket.status, 403); await requestJson(baseUrl, "/v1/openwebrx/tx/enable", { method: "POST", headers, body: {} }); await requestJson(baseUrl, "/v1/station/release", { method: "POST", headers, body: {} }); const authAfterRelease = await fetch(`${baseUrl}/v1/openwebrx/authorize?ticket=${encodeURIComponent(session.session.ticket)}`); assert.equal(authAfterRelease.status, 403); const txRaw = await fs.readFile(txStatePath, "utf8"); const txState = JSON.parse(txRaw); assert.equal(Boolean(txState.txActive), false); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); test("help content endpoint is auth-only and returns structured content", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-help-test-")); 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 unauth = await fetch(`${baseUrl}/v1/help/content`); assert.equal(unauth.status, 401); const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); const headers = { Authorization: `Bearer ${adminVerify.accessToken}` }; const help = await requestJson(baseUrl, "/v1/help/content", { headers }); assert.ok(help.content); assert.equal(help.content.title, "RMS Hilfe"); assert.ok(help.content.quickStart); assert.ok(Array.isArray(help.content.quickStart.steps)); assert.ok(help.content.quickStart.steps.length >= 3); assert.ok(Array.isArray(help.content.sections)); assert.ok(help.content.sections.length >= 3); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); test("openwebrx bandmap endpoints work for active owner and access policy syncs owner", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-openwebrx-band-test-")); const port = randomPort(); const baseUrl = `http://127.0.0.1:${port}`; const policyPath = path.join(dataDir, "openwebrx-access-policy.txt"); 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, OPENWEBRX_ACCESS_POLICY_FILE: policyPath, OPENWEBRX_BANDMAP: "80;3650000;80m;draht,40;7150000;40m;draht,20;14300000;20m;beam" }, 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 headers = { Authorization: `Bearer ${adminVerify.accessToken}` }; await requestJson(baseUrl, "/v1/station/activation-jobs", { method: "POST", headers, body: {} }); await waitForCondition(async () => { const status = await requestJson(baseUrl, "/v1/station/status", { headers }); return status.isInUse ? status : null; }, 20000, 250); const bands = await requestJson(baseUrl, "/v1/openwebrx/bands", { headers }); assert.equal(bands.ok, true); assert.ok(Array.isArray(bands.bands)); assert.ok(bands.bands.length > 0); const firstBand = String(bands.bands[0].band); const selected = await requestJson(baseUrl, "/v1/openwebrx/bands/select", { method: "POST", headers, body: { band: firstBand } }); assert.equal(selected.ok, true); assert.equal(String(selected.result.selectedBand), firstBand); const policyRawActive = await fs.readFile(policyPath, "utf8"); assert.ok(policyRawActive.includes("admin@arcg.at")); await requestJson(baseUrl, "/v1/station/release", { method: "POST", headers, body: {} }); await assert.rejects( requestJson(baseUrl, "/v1/openwebrx/bands", { headers }), (error) => error && error.status === 403 && error.payload && error.payload.error && error.payload.error.code === "openwebrx.not_owner" ); const policyRawReleased = await fs.readFile(policyPath, "utf8"); assert.equal(policyRawReleased.trim(), ""); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); test("openwebrx tx status is owner-only and reflects tx enable", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-openwebrx-tx-status-test-")); 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 owner = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); const other = await requestAccessAndVerify(baseUrl, dataDir, "operator2@arcg.at"); const ownerHeaders = { Authorization: `Bearer ${owner.accessToken}` }; const otherHeaders = { Authorization: `Bearer ${other.accessToken}` }; await requestJson(baseUrl, "/v1/station/activation-jobs", { method: "POST", headers: ownerHeaders, body: {} }); await waitForCondition(async () => { const status = await requestJson(baseUrl, "/v1/station/status", { headers: ownerHeaders }); return status.isInUse ? status : null; }, 20000, 250); const initialStatus = await requestJson(baseUrl, "/v1/openwebrx/tx/status", { headers: ownerHeaders }); assert.equal(initialStatus.ok, true); assert.equal(Boolean(initialStatus.txActive), false); await requestJson(baseUrl, "/v1/openwebrx/tx/enable", { method: "POST", headers: ownerHeaders, body: {} }); const afterEnableStatus = await requestJson(baseUrl, "/v1/openwebrx/tx/status", { headers: ownerHeaders }); assert.equal(afterEnableStatus.ok, true); assert.equal(Boolean(afterEnableStatus.txActive), true); await assert.rejects( requestJson(baseUrl, "/v1/openwebrx/tx/status", { headers: otherHeaders }), (error) => error && error.status === 403 && error.payload && error.payload.error && error.payload.error.code === "openwebrx.not_owner" ); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); test("failed activation clears running state and exposes error in status", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-activation-fail-status-test-")); 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, RMS_EXEC_MODE: "prod", SCRIPT_ACTIVATE: "definitely-not-a-command", 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 headers = { Authorization: `Bearer ${adminVerify.accessToken}` }; await requestJson(baseUrl, "/v1/station/activation-jobs", { method: "POST", headers, body: {} }); const failedStatus = await waitForCondition(async () => { const status = await requestJson(baseUrl, "/v1/station/status", { headers }); if (status.activation && status.activation.running) { return null; } if (status.activation && status.activation.lastStatus === "failed") { return status; } return null; }, 20000, 250); assert.equal(Boolean(failedStatus.isInUse), false); assert.equal(failedStatus.activation.lastStatus, "failed"); assert.ok(typeof failedStatus.activation.lastError === "string" && failedStatus.activation.lastError.length > 0); const secondStart = await requestJson(baseUrl, "/v1/station/activation-jobs", { method: "POST", headers, body: {} }); assert.equal(Boolean(secondStart.ok), true); assert.equal(Boolean(secondStart.pending), true); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); test("prod activation tolerates missing default station scripts", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-missing-station-script-test-")); 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, RMS_EXEC_MODE: "prod", SCRIPT_ROOT: "/opt/remotestation", SCRIPT_ACTIVATE: "/opt/remotestation/bin/activate.sh", SCRIPT_DEACTIVATE: "/opt/remotestation/bin/deactivate.sh", 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 headers = { Authorization: `Bearer ${adminVerify.accessToken}` }; await requestJson(baseUrl, "/v1/station/activation-jobs", { method: "POST", headers, body: {} }); await waitForCondition(async () => { const status = await requestJson(baseUrl, "/v1/station/status", { headers }); return status.isInUse ? status : null; }, 20000, 250); await requestJson(baseUrl, "/v1/station/release", { method: "POST", headers, body: {} }); const released = await waitForCondition(async () => { const status = await requestJson(baseUrl, "/v1/station/status", { headers }); return status.isInUse ? null : status; }, 10000, 250); assert.equal(Boolean(released.isInUse), false); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } }); test("prod mode auto-disables TX before station activation", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-prod-auto-txoff-test-")); const port = randomPort(); const baseUrl = `http://127.0.0.1:${port}`; const txStatePath = path.join(dataDir, "tx-state.json"); await fs.writeFile(txStatePath, JSON.stringify({ txActive: true, updatedAt: new Date().toISOString(), source: "test" })); const server = spawn(process.execPath, ["server/index.js"], { cwd: rootDir, env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, RMS_EXEC_MODE: "prod", AUTO_DISABLE_TX_BEFORE_ACTIVATION: "true", TX_STATE_PATH: txStatePath, TX_DISABLE_CMD: "true", 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 headers = { Authorization: `Bearer ${adminVerify.accessToken}` }; await requestJson(baseUrl, "/v1/station/activation-jobs", { method: "POST", headers, body: {} }); const status = await waitForCondition(async () => { const current = await requestJson(baseUrl, "/v1/station/status", { headers }); return current.isInUse ? current : null; }, 20000, 250); assert.equal(Boolean(status.isInUse), true); const txRaw = await fs.readFile(txStatePath, "utf8"); const txState = JSON.parse(txRaw); assert.equal(Boolean(txState.txActive), false); if (server.exitCode !== null && server.exitCode !== 0) { throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); } });