2105 lines
70 KiB
JavaScript
2105 lines
70 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("admin can delete individual approval and activity entries", async (t) => {
|
|
const rootDir = path.resolve(__dirname, "..");
|
|
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-delete-entries-test-"));
|
|
const port = randomPort();
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
|
|
const server = spawn(process.execPath, ["server/index.js"], {
|
|
cwd: rootDir,
|
|
env: {
|
|
...process.env,
|
|
PORT: String(port),
|
|
DATA_DIR: dataDir,
|
|
ADMIN_EMAILS: "admin@arcg.at",
|
|
PRIMARY_EMAIL_DOMAIN: "arcg.at",
|
|
PUBLIC_BASE_URL: baseUrl
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
});
|
|
|
|
let serverStdErr = "";
|
|
server.stderr.on("data", (chunk) => {
|
|
serverStdErr += String(chunk);
|
|
});
|
|
|
|
t.after(async () => {
|
|
if (!server.killed) {
|
|
server.kill("SIGTERM");
|
|
}
|
|
await fs.rm(dataDir, { recursive: true, force: true });
|
|
});
|
|
|
|
await waitForServer(baseUrl);
|
|
|
|
const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at");
|
|
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` };
|
|
|
|
const externalEmail = "deleteme@oevsv.at";
|
|
const verify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail);
|
|
assert.equal(verify.approved, false);
|
|
|
|
const approvalsBefore = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders });
|
|
const pending = (approvalsBefore.approvals || []).find((entry) => entry.email === externalEmail);
|
|
assert.ok(pending, "approval entry should exist before delete");
|
|
|
|
const deleteApproval = await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}`, {
|
|
method: "DELETE",
|
|
headers: adminHeaders
|
|
});
|
|
assert.equal(deleteApproval.ok, true);
|
|
assert.equal(deleteApproval.deletedId, pending.id);
|
|
|
|
const approvalsAfter = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders });
|
|
assert.equal((approvalsAfter.approvals || []).some((entry) => entry.id === pending.id), false);
|
|
|
|
const activityBefore = await requestJson(baseUrl, "/v1/activity-log?limit=300", { headers: adminHeaders });
|
|
assert.ok(Array.isArray(activityBefore.entries));
|
|
const targetActivity = activityBefore.entries.find((entry) => entry && entry.id && entry.action === "auth.request_access");
|
|
assert.ok(targetActivity, "activity entry with id should exist before delete");
|
|
|
|
const deleteActivity = await requestJson(baseUrl, `/v1/activity-log/${encodeURIComponent(targetActivity.id)}`, {
|
|
method: "DELETE",
|
|
headers: adminHeaders
|
|
});
|
|
assert.equal(deleteActivity.ok, true);
|
|
assert.equal(deleteActivity.deletedId, targetActivity.id);
|
|
|
|
const activityAfter = await requestJson(baseUrl, "/v1/activity-log?limit=300", { headers: adminHeaders });
|
|
assert.equal((activityAfter.entries || []).some((entry) => entry.id === targetActivity.id), false);
|
|
|
|
if (server.exitCode !== null && server.exitCode !== 0) {
|
|
throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`);
|
|
}
|
|
});
|
|
|
|
test("station usage is auto-ended after configured max usage time", async (t) => {
|
|
const rootDir = path.resolve(__dirname, "..");
|
|
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-station-timeout-test-"));
|
|
const port = randomPort();
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
|
|
const server = spawn(process.execPath, ["server/index.js"], {
|
|
cwd: rootDir,
|
|
env: {
|
|
...process.env,
|
|
PORT: String(port),
|
|
DATA_DIR: dataDir,
|
|
ADMIN_EMAILS: "admin@arcg.at",
|
|
PRIMARY_EMAIL_DOMAIN: "arcg.at",
|
|
PUBLIC_BASE_URL: baseUrl,
|
|
STATION_MAX_USAGE_SEC: "2"
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
});
|
|
|
|
let serverStdErr = "";
|
|
server.stderr.on("data", (chunk) => {
|
|
serverStdErr += String(chunk);
|
|
});
|
|
|
|
t.after(async () => {
|
|
if (!server.killed) {
|
|
server.kill("SIGTERM");
|
|
}
|
|
await fs.rm(dataDir, { recursive: true, force: true });
|
|
});
|
|
|
|
await waitForServer(baseUrl);
|
|
|
|
const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at");
|
|
const headers = { Authorization: `Bearer ${adminVerify.accessToken}` };
|
|
|
|
await requestJson(baseUrl, "/v1/station/activation-jobs", {
|
|
method: "POST",
|
|
headers,
|
|
body: {}
|
|
});
|
|
|
|
const activeStatus = await waitForCondition(async () => {
|
|
const status = await requestJson(baseUrl, "/v1/station/status", { headers });
|
|
return status.isInUse ? status : null;
|
|
}, 20000, 250);
|
|
|
|
assert.ok(activeStatus.endsAt, "activation should set end time");
|
|
|
|
const releasedStatus = await waitForCondition(async () => {
|
|
const status = await requestJson(baseUrl, "/v1/station/status", { headers });
|
|
return status.isInUse ? null : status;
|
|
}, 15000, 300);
|
|
|
|
assert.equal(releasedStatus.isInUse, false);
|
|
assert.equal(releasedStatus.activeByEmail, null);
|
|
assert.equal(releasedStatus.endsAt, null);
|
|
|
|
if (server.exitCode !== null && server.exitCode !== 0) {
|
|
throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`);
|
|
}
|
|
});
|
|
|
|
test("activity log endpoint lists link requests and station operations", async (t) => {
|
|
const rootDir = path.resolve(__dirname, "..");
|
|
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-activity-log-test-"));
|
|
const port = randomPort();
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
|
|
const server = spawn(process.execPath, ["server/index.js"], {
|
|
cwd: rootDir,
|
|
env: {
|
|
...process.env,
|
|
PORT: String(port),
|
|
DATA_DIR: dataDir,
|
|
ADMIN_EMAILS: "admin@arcg.at",
|
|
PRIMARY_EMAIL_DOMAIN: "arcg.at",
|
|
PUBLIC_BASE_URL: baseUrl
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
});
|
|
|
|
let serverStdErr = "";
|
|
server.stderr.on("data", (chunk) => {
|
|
serverStdErr += String(chunk);
|
|
});
|
|
|
|
t.after(async () => {
|
|
if (!server.killed) {
|
|
server.kill("SIGTERM");
|
|
}
|
|
await fs.rm(dataDir, { recursive: true, force: true });
|
|
});
|
|
|
|
await waitForServer(baseUrl);
|
|
|
|
const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at");
|
|
const headers = { Authorization: `Bearer ${adminVerify.accessToken}` };
|
|
|
|
const approverVerify = await requestAccessAndVerify(baseUrl, dataDir, "logviewer@arcg.at");
|
|
const users = await requestJson(baseUrl, "/v1/admin/users", { headers });
|
|
const approverUser = (users.users || []).find((entry) => entry.email === "logviewer@arcg.at");
|
|
assert.ok(approverUser);
|
|
await requestJson(baseUrl, `/v1/admin/users/${encodeURIComponent(approverUser.id)}/role`, {
|
|
method: "PUT",
|
|
headers,
|
|
body: { role: "approver" }
|
|
});
|
|
const approverLogin = await requestAccessAndVerify(baseUrl, dataDir, "logviewer@arcg.at");
|
|
const approverHeaders = { Authorization: `Bearer ${approverLogin.accessToken}` };
|
|
|
|
await requestJson(baseUrl, "/v1/auth/request-access", {
|
|
method: "POST",
|
|
body: { email: "viewer@arcg.at" }
|
|
});
|
|
|
|
await requestJson(baseUrl, "/v1/station/activation-jobs", {
|
|
method: "POST",
|
|
headers,
|
|
body: {}
|
|
});
|
|
|
|
await waitForCondition(async () => {
|
|
const status = await requestJson(baseUrl, "/v1/station/status", { headers });
|
|
return status.isInUse ? true : null;
|
|
}, 20000, 250);
|
|
|
|
await requestJson(baseUrl, "/v1/station/release", {
|
|
method: "POST",
|
|
headers,
|
|
body: {}
|
|
});
|
|
|
|
const log = await requestJson(baseUrl, "/v1/activity-log?limit=200", { headers });
|
|
assert.ok(Array.isArray(log.entries));
|
|
const actions = new Set(log.entries.map((entry) => entry.action));
|
|
assert.ok(actions.has("auth.request_access"));
|
|
assert.ok(actions.has("station.activate.start"));
|
|
assert.ok(actions.has("station.activate.done"));
|
|
assert.ok(actions.has("station.deactivate"));
|
|
|
|
const approverLog = await requestJson(baseUrl, "/v1/activity-log?limit=50", { headers: approverHeaders });
|
|
assert.ok(Array.isArray(approverLog.entries));
|
|
assert.ok(approverLog.entries.length > 0);
|
|
|
|
if (server.exitCode !== null && server.exitCode !== 0) {
|
|
throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`);
|
|
}
|
|
});
|
|
|
|
test("admin can upload theme logos and public system exposes branding URLs", async (t) => {
|
|
const rootDir = path.resolve(__dirname, "..");
|
|
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-branding-upload-test-"));
|
|
const port = randomPort();
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
|
|
const server = spawn(process.execPath, ["server/index.js"], {
|
|
cwd: rootDir,
|
|
env: {
|
|
...process.env,
|
|
PORT: String(port),
|
|
DATA_DIR: dataDir,
|
|
ADMIN_EMAILS: "admin@arcg.at",
|
|
PRIMARY_EMAIL_DOMAIN: "arcg.at",
|
|
PUBLIC_BASE_URL: baseUrl
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
});
|
|
|
|
let serverStdErr = "";
|
|
server.stderr.on("data", (chunk) => {
|
|
serverStdErr += String(chunk);
|
|
});
|
|
|
|
t.after(async () => {
|
|
if (!server.killed) {
|
|
server.kill("SIGTERM");
|
|
}
|
|
await fs.rm(dataDir, { recursive: true, force: true });
|
|
});
|
|
|
|
await waitForServer(baseUrl);
|
|
|
|
const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at");
|
|
const headers = { Authorization: `Bearer ${adminVerify.accessToken}` };
|
|
|
|
const tinyPng = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO6X3mQAAAAASUVORK5CYII=";
|
|
|
|
await requestJson(baseUrl, "/v1/admin/branding/logo", {
|
|
method: "PUT",
|
|
headers,
|
|
body: { theme: "light", dataUrl: tinyPng, fileName: "light.png" }
|
|
});
|
|
await requestJson(baseUrl, "/v1/admin/branding/logo", {
|
|
method: "PUT",
|
|
headers,
|
|
body: { theme: "dark", dataUrl: tinyPng, fileName: "dark.png" }
|
|
});
|
|
|
|
const system = await requestJson(baseUrl, "/v1/public/system");
|
|
assert.ok(system.branding);
|
|
assert.equal(system.branding.logoLightUrl, "/uploads/logo-light.png");
|
|
assert.equal(system.branding.logoDarkUrl, "/uploads/logo-dark.png");
|
|
|
|
const lightRes = await fetch(`${baseUrl}${system.branding.logoLightUrl}`);
|
|
assert.equal(lightRes.status, 200);
|
|
const darkRes = await fetch(`${baseUrl}${system.branding.logoDarkUrl}`);
|
|
assert.equal(darkRes.status, 200);
|
|
|
|
await requestJson(baseUrl, "/v1/admin/branding/logo?theme=light", {
|
|
method: "DELETE",
|
|
headers
|
|
});
|
|
const systemAfterDelete = await requestJson(baseUrl, "/v1/public/system");
|
|
assert.equal(systemAfterDelete.branding.logoLightUrl, null);
|
|
assert.equal(systemAfterDelete.branding.logoDarkUrl, "/uploads/logo-dark.png");
|
|
|
|
const lightResAfterDelete = await fetch(`${baseUrl}/uploads/logo-light.png`);
|
|
assert.equal(lightResAfterDelete.status, 404);
|
|
|
|
if (server.exitCode !== null && server.exitCode !== 0) {
|
|
throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`);
|
|
}
|
|
});
|
|
|
|
test("swr report endpoint returns band data and supports manual run trigger", async (t) => {
|
|
const rootDir = path.resolve(__dirname, "..");
|
|
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-swr-report-test-"));
|
|
const port = randomPort();
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
|
|
const server = spawn(process.execPath, ["server/index.js"], {
|
|
cwd: rootDir,
|
|
env: {
|
|
...process.env,
|
|
PORT: String(port),
|
|
DATA_DIR: dataDir,
|
|
ADMIN_EMAILS: "admin@arcg.at",
|
|
PRIMARY_EMAIL_DOMAIN: "arcg.at",
|
|
PUBLIC_BASE_URL: baseUrl
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
});
|
|
|
|
let serverStdErr = "";
|
|
server.stderr.on("data", (chunk) => {
|
|
serverStdErr += String(chunk);
|
|
});
|
|
|
|
t.after(async () => {
|
|
if (!server.killed) {
|
|
server.kill("SIGTERM");
|
|
}
|
|
await fs.rm(dataDir, { recursive: true, force: true });
|
|
});
|
|
|
|
await waitForServer(baseUrl);
|
|
const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at");
|
|
const headers = { Authorization: `Bearer ${adminVerify.accessToken}` };
|
|
|
|
const report = await requestJson(baseUrl, "/v1/swr/report", { headers });
|
|
assert.ok(Array.isArray(report.bands));
|
|
assert.ok(report.bands.length >= 8);
|
|
assert.ok(report.bands.some((entry) => entry.band === "20m"));
|
|
|
|
const run = await requestJson(baseUrl, "/v1/swr/run-check", {
|
|
method: "POST",
|
|
headers,
|
|
body: {}
|
|
});
|
|
assert.equal(run.ok, true);
|
|
assert.ok(run.report);
|
|
|
|
const staleLegacyPath = path.join(dataDir, "vswr", "legacy-report.json");
|
|
await fs.mkdir(path.dirname(staleLegacyPath), { recursive: true });
|
|
await fs.writeFile(staleLegacyPath, JSON.stringify({
|
|
generatedAt: "2000-01-01T00:00:00.000Z",
|
|
overallStatus: "FAILED",
|
|
bands: [
|
|
{ band: "20m", status: "FAILED", error: "stale legacy" }
|
|
]
|
|
}, null, 2));
|
|
|
|
const refreshedReport = await requestJson(baseUrl, "/v1/swr/report", { headers });
|
|
assert.equal(refreshedReport.generatedAt, run.report.generatedAt);
|
|
const refreshed20m = Array.isArray(refreshedReport.bands)
|
|
? refreshedReport.bands.find((entry) => entry && entry.band === "20m")
|
|
: null;
|
|
assert.ok(refreshed20m);
|
|
assert.notEqual(String(refreshed20m.status || "").toUpperCase(), "FAILED");
|
|
|
|
await requestJson(baseUrl, "/v1/station/activation-jobs", {
|
|
method: "POST",
|
|
headers,
|
|
body: {}
|
|
});
|
|
await waitForCondition(async () => {
|
|
const status = await requestJson(baseUrl, "/v1/station/status", { headers });
|
|
return status.isInUse ? status : null;
|
|
}, 20000, 250);
|
|
|
|
await assert.rejects(
|
|
requestJson(baseUrl, "/v1/swr/run-check", {
|
|
method: "POST",
|
|
headers,
|
|
body: {}
|
|
}),
|
|
(error) => error && error.status === 409 && error.payload && error.payload.error && error.payload.error.code === "vswr.unsafe_station_active"
|
|
);
|
|
|
|
await requestJson(baseUrl, "/v1/station/release", {
|
|
method: "POST",
|
|
headers,
|
|
body: {}
|
|
});
|
|
|
|
if (server.exitCode !== null && server.exitCode !== 0) {
|
|
throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`);
|
|
}
|
|
});
|
|
|
|
test("tx-active lock blocks any switching and swr run", async (t) => {
|
|
const rootDir = path.resolve(__dirname, "..");
|
|
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-tx-lock-test-"));
|
|
const port = randomPort();
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
const txStatePath = path.join(dataDir, "tx-state.json");
|
|
await fs.writeFile(txStatePath, JSON.stringify({ txActive: true, updatedAt: new Date().toISOString(), source: "test" }));
|
|
|
|
const server = spawn(process.execPath, ["server/index.js"], {
|
|
cwd: rootDir,
|
|
env: {
|
|
...process.env,
|
|
PORT: String(port),
|
|
DATA_DIR: dataDir,
|
|
TX_STATE_PATH: txStatePath,
|
|
ADMIN_EMAILS: "admin@arcg.at",
|
|
PRIMARY_EMAIL_DOMAIN: "arcg.at",
|
|
PUBLIC_BASE_URL: baseUrl
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
});
|
|
|
|
let serverStdErr = "";
|
|
server.stderr.on("data", (chunk) => {
|
|
serverStdErr += String(chunk);
|
|
});
|
|
|
|
t.after(async () => {
|
|
if (!server.killed) {
|
|
server.kill("SIGTERM");
|
|
}
|
|
await fs.rm(dataDir, { recursive: true, force: true });
|
|
});
|
|
|
|
await waitForServer(baseUrl);
|
|
const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at");
|
|
const headers = { Authorization: `Bearer ${adminVerify.accessToken}` };
|
|
|
|
await assert.rejects(
|
|
requestJson(baseUrl, "/v1/swr/run-check", {
|
|
method: "POST",
|
|
headers,
|
|
body: {}
|
|
}),
|
|
(error) => error && error.status === 409 && error.payload && error.payload.error && error.payload.error.code === "tx.switch_locked"
|
|
);
|
|
|
|
await assert.rejects(
|
|
requestJson(baseUrl, "/v1/station/activation-jobs", {
|
|
method: "POST",
|
|
headers,
|
|
body: {}
|
|
}),
|
|
(error) => error && error.status === 409 && error.payload && error.payload.error && error.payload.error.code === "tx.switch_locked"
|
|
);
|
|
|
|
await assert.rejects(
|
|
requestJson(baseUrl, "/v1/ui/controls/rfroute-main/actions/setRoute", {
|
|
method: "POST",
|
|
headers,
|
|
body: { input: { route: "beam" } }
|
|
}),
|
|
(error) => error && error.status === 409 && error.payload && error.payload.error && error.payload.error.code === "tx.switch_locked"
|
|
);
|
|
|
|
await fs.writeFile(txStatePath, JSON.stringify({ txActive: false, updatedAt: new Date().toISOString(), source: "test" }));
|
|
|
|
const unlockRun = await requestJson(baseUrl, "/v1/swr/run-check", {
|
|
method: "POST",
|
|
headers,
|
|
body: {}
|
|
});
|
|
assert.equal(unlockRun.ok, true);
|
|
|
|
if (server.exitCode !== null && server.exitCode !== 0) {
|
|
throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`);
|
|
}
|
|
});
|
|
|
|
test("openwebrx session is owner-bound and release disables tx first", async (t) => {
|
|
const rootDir = path.resolve(__dirname, "..");
|
|
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-openwebrx-test-"));
|
|
const port = randomPort();
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
const txStatePath = path.join(dataDir, "tx-state.json");
|
|
|
|
const server = spawn(process.execPath, ["server/index.js"], {
|
|
cwd: rootDir,
|
|
env: {
|
|
...process.env,
|
|
PORT: String(port),
|
|
DATA_DIR: dataDir,
|
|
TX_STATE_PATH: txStatePath,
|
|
ADMIN_EMAILS: "admin@arcg.at",
|
|
PRIMARY_EMAIL_DOMAIN: "arcg.at",
|
|
PUBLIC_BASE_URL: baseUrl,
|
|
OPENWEBRX_PATH: "/openwebrx/"
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
});
|
|
|
|
let serverStdErr = "";
|
|
server.stderr.on("data", (chunk) => {
|
|
serverStdErr += String(chunk);
|
|
});
|
|
|
|
t.after(async () => {
|
|
if (!server.killed) {
|
|
server.kill("SIGTERM");
|
|
}
|
|
await fs.rm(dataDir, { recursive: true, force: true });
|
|
});
|
|
|
|
await waitForServer(baseUrl);
|
|
const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at");
|
|
const headers = { Authorization: `Bearer ${adminVerify.accessToken}` };
|
|
|
|
await requestJson(baseUrl, "/v1/station/activation-jobs", {
|
|
method: "POST",
|
|
headers,
|
|
body: {}
|
|
});
|
|
await waitForCondition(async () => {
|
|
const status = await requestJson(baseUrl, "/v1/station/status", { headers });
|
|
return status.isInUse ? status : null;
|
|
}, 20000, 250);
|
|
|
|
const session = await requestJson(baseUrl, "/v1/openwebrx/session", {
|
|
method: "POST",
|
|
headers,
|
|
body: {}
|
|
});
|
|
assert.equal(session.ok, true);
|
|
assert.ok(session.session.ticket);
|
|
|
|
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}`);
|
|
}
|
|
});
|