Files
ARCG-Remote-Station-Software/test/auth-methods.integration.test.js
OE6DXD e1a4ce0b8b initialize generic rms-software repository
Add the reusable RMS core application (server, web UI, plugins, tests, tools) with generic defaults, GPL licensing, and maintainer context documentation so deployments can consume this repo as software source independent of station-specific overlays.
2026-03-16 03:31:08 +01:00

1944 lines
64 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}` };
const guestStart = await requestJson(baseUrl, "/v1/auth/request-access", {
method: "POST",
body: { email: "guest@example.com" }
});
const outbox2 = await readOutbox(dataDir);
const guestVerifyMail = [...outbox2].reverse().find((entry) => entry.to === "guest@example.com");
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@example.com", 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@example.com" && 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@example.com");
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@example.com", method: "otp-email" }
});
assert.equal(otpStart.challengeType, "otp", "OTP challenge should be selected");
const outbox3 = await readOutbox(dataDir);
const guestOtpMail = [...outbox3]
.reverse()
.find((entry) => entry.to === "guest@example.com" && /Code lautet:\s*\d{6}/.test(entry.text));
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@example.com",
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("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@outside.example");
assert.equal(externalVerify.approved, false, "external user should require approval");
const approvalsForApprover = await requestJson(baseUrl, "/v1/approvals", { headers: approverHeaders });
const pending = (approvalsForApprover.approvals || []).find((entry) => entry.email === "remote@outside.example" && entry.status === "pending");
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@outside.example");
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@external.example";
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@external.example";
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@outside.example";
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}`);
}
});