Files
ARCG-Remote-Station-Software/test/auth-methods.integration.test.js

2031 lines
67 KiB
JavaScript

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("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);
const authOk = await fetch(`${baseUrl}/v1/openwebrx/authorize?ticket=${encodeURIComponent(session.session.ticket)}`);
assert.equal(authOk.status, 200);
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}`);
}
});