You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dvmhost/tests/fne/FNE_RESTAPI_Tests.cpp

462 lines
19 KiB

// SPDX-License-Identifier: GPL-2.0-only
/*
* Digital Voice Modem - Test Suite
* GPLv2 Open Source. Use is subject to license terms.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* Copyright (C) 2026 Bryan Biedenkapp, N2PLL
*/
#include <catch2/catch_test_macros.hpp>
#include "common/lookups/RadioAliasLookup.h"
#include "common/lookups/RadioIdLookup.h"
#include "common/lookups/AdjSiteMapLookup.h"
#include "common/lookups/PeerListLookup.h"
#include "common/lookups/TalkgroupRulesLookup.h"
#include "common/restapi/http/HTTPPayload.h"
#include "fne/restapi/RESTAPI.h"
#include "fne/restapi/RESTDefines.h"
#include "remote/RESTClient.h"
#include <asio.hpp>
#include <chrono>
#include <cstdio>
#include <fstream>
#include <iterator>
#include <string>
#include <vector>
using namespace restapi::http;
using namespace fne_restapi;
namespace {
// ---------------------------------------------------------------------------
// Global Variables
// ---------------------------------------------------------------------------
constexpr uint32_t TEST_RID = 123456U;
const std::string TEST_PASSWORD = "fne-rest-test-password";
// ---------------------------------------------------------------------------
// Global Functions
// ---------------------------------------------------------------------------
/**
* @brief Reserves an available loopback port.
* @returns uint16_t
*/
uint16_t reserveLoopbackPort()
{
asio::io_service ioService;
asio::ip::tcp::acceptor acceptor(ioService,
asio::ip::tcp::endpoint(asio::ip::address_v4::loopback(), 0U));
return acceptor.local_endpoint().port();
}
/**
* @brief Generates a temporary file path for the ACL file.
* @returns std::string
*/
std::string temporaryACLPath()
{
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
return "/tmp/dvm-fne-rest-rid-" + std::to_string(stamp) + ".csv";
}
/**
* @brief Reads the contents of a lookup file, returning an empty string if it does not exist.
* @param path The lookup file path.
* @returns std::string
*/
std::string readFileContents(const std::string& path)
{
std::ifstream file(path);
return std::string((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
}
/**
* @brief Sends a REST API request to the specified endpoint with the given payload.
* @param port The port on which the REST API server is running.
* @param method The HTTP method to use for the request.
* @param endpoint The REST API endpoint to send the request to.
* @param payload The JSON payload to include in the request.
* @return The JSON response from the server.
*/
json::object sendRequest(uint16_t port, const std::string& method,
const std::string& endpoint, json::object payload = json::object())
{
json::object response = json::object();
REQUIRE(RESTClient::send("127.0.0.1", port, TEST_PASSWORD, method, endpoint,
payload, response, false, 1000, false) == HTTPPayload::OK);
REQUIRE(response["status"].get<int>() == HTTPPayload::OK);
return response;
}
/**
* @brief Creates a JSON payload for a talkgroup with the specified parameters.
* @param tgid The talkgroup ID.
* @param slot The slot number.
* @param name The name of the talkgroup.
* @return The JSON object representing the talkgroup.
*/
json::object talkgroupPayload(uint32_t tgid, uint8_t slot, const std::string& name)
{
json::object source = json::object();
source["tgid"].set<uint32_t>(tgid);
source["slot"].set<uint8_t>(slot);
json::object config = json::object();
config["active"] = json::value(true);
config["affiliated"] = json::value(false);
config["parrot"] = json::value(false);
config["strapping"] = json::value((double)lookups::TG_STRAPPING_SELECTABLE);
config["inclusion"].set<json::array>(json::array());
config["exclusion"].set<json::array>(json::array());
config["rewrite"].set<json::array>(json::array());
config["always"].set<json::array>(json::array());
config["preferred"].set<json::array>(json::array());
config["permittedRids"].set<json::array>(json::array());
json::object tg = json::object();
tg["name"].set<std::string>(name);
tg["alias"].set<std::string>(name + " alias");
tg["source"].set<json::object>(source);
tg["config"].set<json::object>(config);
return tg;
}
// ---------------------------------------------------------------------------
// Class Declaration
// ---------------------------------------------------------------------------
/**
* @brief Scoped wrapper for a temporary ACL file.
*/
class ScopedACLFile {
public:
/**
* @brief Initializes a new instance of the ScopedACLFile class.
*/
ScopedACLFile() : path(temporaryACLPath()) { }
/**
* @brief Finalizes a instance of the ScopedACLFile class.
*/
~ScopedACLFile() { std::remove(path.c_str()); }
std::string path;
};
// ---------------------------------------------------------------------------
// Class Declaration
// ---------------------------------------------------------------------------
/**
* @brief Scoped wrapper for the RESTAPI instance used in tests.
*/
class ScopedRESTAPI {
public:
/**
* @brief Initializes a new instance of the ScopedRESTAPI class.
* @param port The port on which the REST API server will listen.
* @param ridLookup The Radio ID lookup instance.
* @param ridAliasLookup The Radio Alias lookup instance.
* @param tgLookup The Talkgroup Rules lookup instance.
* @param peerLookup The Peer List lookup instance.
* @param adjLookup The Adjacent Site Map lookup instance.
*/
ScopedRESTAPI(uint16_t port, lookups::RadioIdLookup* ridLookup = nullptr,
lookups::RadioAliasLookup* ridAliasLookup = nullptr,
lookups::TalkgroupRulesLookup* tgLookup = nullptr,
lookups::PeerListLookup* peerLookup = nullptr,
lookups::AdjSiteMapLookup* adjLookup = nullptr) :
api("127.0.0.1", port, TEST_PASSWORD, "", "", false, nullptr, false)
{
api.setLookups(ridLookup, ridAliasLookup, tgLookup, peerLookup, adjLookup, nullptr);
REQUIRE(api.open());
}
/**
* @brief Finalizes a instance of the ScopedRESTAPI class.
*/
~ScopedRESTAPI() { api.close(); }
fne_restapi::RESTAPI api;
};
} // namespace
TEST_CASE("FNE REST RID add and commit updates the persisted ACL", "[fne][restapi][rid]")
{
ScopedACLFile aclFile;
lookups::RadioIdLookup lookup(aclFile.path, 0U, true);
const std::string initialContents = readFileContents(aclFile.path);
const uint16_t port = reserveLoopbackPort();
ScopedRESTAPI server(port, &lookup);
json::object add = json::object();
add["rid"] = json::value((double)TEST_RID);
add["enabled"] = json::value(true);
add["alias"].set<std::string>("REST test radio");
add["canRequestKeys"] = json::value(true);
add["canRekey"] = json::value(true);
json::array allowedKIds = json::array();
allowedKIds.push_back(json::value((double)0x1234U));
allowedKIds.push_back(json::value((double)0xABCDU));
add["allowedKIds"].set<json::array>(allowedKIds);
sendRequest(port, HTTP_PUT, FNE_PUT_RID_ADD, add);
CHECK(readFileContents(aclFile.path) == initialContents);
const lookups::RadioId added = lookup.find(TEST_RID);
REQUIRE_FALSE(added.radioDefault());
CHECK(added.radioEnabled());
CHECK(added.radioAlias() == "REST test radio");
CHECK(added.canRequestKeys());
CHECK(added.canRekey());
CHECK(added.allowedKIds() == std::vector<uint16_t> { 0x1234U, 0xABCDU });
sendRequest(port, HTTP_GET, FNE_GET_RID_COMMIT);
const std::string committedContents = readFileContents(aclFile.path);
CHECK(committedContents != initialContents);
CHECK(committedContents.find(std::to_string(TEST_RID)) != std::string::npos);
lookups::RadioIdLookup persisted(aclFile.path, 0U, true);
REQUIRE(persisted.read());
const lookups::RadioId saved = persisted.find(TEST_RID);
REQUIRE_FALSE(saved.radioDefault());
CHECK(saved.radioAlias() == "REST test radio");
CHECK(saved.allowedKIds() == std::vector<uint16_t> { 0x1234U, 0xABCDU });
}
TEST_CASE("FNE REST RID delete and commit removes the RID from the ACL", "[fne][restapi][rid]")
{
ScopedACLFile aclFile;
lookups::RadioIdLookup lookup(aclFile.path, 0U, true);
lookup.addEntry(TEST_RID, true, "radio to delete");
lookup.commit(true);
const std::string committedContents = readFileContents(aclFile.path);
const uint16_t port = reserveLoopbackPort();
ScopedRESTAPI server(port, &lookup);
json::object remove = json::object();
remove["rid"] = json::value((double)TEST_RID);
sendRequest(port, HTTP_PUT, FNE_PUT_RID_DELETE, remove);
CHECK(lookup.find(TEST_RID).radioDefault());
CHECK(readFileContents(aclFile.path) == committedContents);
sendRequest(port, HTTP_GET, FNE_GET_RID_COMMIT);
const std::string contents = readFileContents(aclFile.path);
CHECK(contents != committedContents);
CHECK(contents.find(std::to_string(TEST_RID)) == std::string::npos);
}
TEST_CASE("FNE REST RID alias endpoints query, persist, and delete aliases", "[fne][restapi][rid][alias]")
{
ScopedACLFile aliasFile;
lookups::RadioAliasLookup lookup(aliasFile.path, 0U, true);
const std::string initialContents = readFileContents(aliasFile.path);
const uint16_t port = reserveLoopbackPort();
ScopedRESTAPI server(port, nullptr, &lookup);
json::object add = json::object();
add["rid"] = json::value((double)TEST_RID);
add["alias"].set<std::string>("REST test alias");
sendRequest(port, HTTP_PUT, FNE_PUT_RID_ALIAS_ADD, add);
CHECK(lookup.find(TEST_RID) == "REST test alias");
CHECK(readFileContents(aliasFile.path) == initialContents);
json::object query = sendRequest(port, HTTP_GET, FNE_GET_RID_ALIAS_QUERY);
const json::array aliases = query["rids"].get<json::array>();
REQUIRE(aliases.size() == 1U);
json::object queriedAlias = aliases[0U].get<json::object>();
CHECK(queriedAlias["id"].get<uint32_t>() == TEST_RID);
CHECK(queriedAlias["alias"].get<std::string>() == "REST test alias");
sendRequest(port, HTTP_GET, FNE_GET_RID_ALIAS_COMMIT);
const std::string committedContents = readFileContents(aliasFile.path);
CHECK(committedContents != initialContents);
CHECK(committedContents.find(std::to_string(TEST_RID)) != std::string::npos);
lookups::RadioAliasLookup persisted(aliasFile.path, 0U, true);
REQUIRE(persisted.read());
CHECK(persisted.find(TEST_RID) == "REST test alias");
json::object remove = json::object();
remove["rid"] = json::value((double)TEST_RID);
sendRequest(port, HTTP_PUT, FNE_PUT_RID_ALIAS_DELETE, remove);
CHECK(lookup.find(TEST_RID) == "UNKNOWN");
CHECK(readFileContents(aliasFile.path) == committedContents);
sendRequest(port, HTTP_GET, FNE_GET_RID_ALIAS_COMMIT);
const std::string contents = readFileContents(aliasFile.path);
CHECK(contents != committedContents);
CHECK(contents.find(std::to_string(TEST_RID)) == std::string::npos);
}
TEST_CASE("FNE REST peer endpoints query, persist, and delete ACL entries", "[fne][restapi][peer]")
{
ScopedACLFile aclFile;
lookups::PeerListLookup lookup(aclFile.path, 0U, true, false);
const std::string initialContents = readFileContents(aclFile.path);
const uint16_t port = reserveLoopbackPort();
ScopedRESTAPI server(port, nullptr, nullptr, nullptr, &lookup);
json::object add = json::object();
add["peerId"] = json::value((double)9001U);
add["peerAlias"].set<std::string>("REST peer");
add["peerPassword"].set<std::string>("peer secret");
add["peerReplica"] = json::value(true);
add["canRequestKeys"] = json::value(true);
add["canIssueInhibit"] = json::value(true);
add["hasCallPriority"] = json::value(true);
sendRequest(port, HTTP_PUT, FNE_PUT_PEER_ADD, add);
CHECK(readFileContents(aclFile.path) == initialContents);
const lookups::PeerId peer = lookup.find(9001U);
CHECK(peer.peerAlias() == "REST peer");
CHECK(peer.peerPassword() == "peer secret");
CHECK(peer.peerReplica());
CHECK(peer.canRequestKeys());
CHECK(peer.canIssueInhibit());
CHECK(peer.hasCallPriority());
json::object query = sendRequest(port, HTTP_GET, FNE_GET_PEER_LIST);
REQUIRE(query["peers"].get<json::array>().size() == 1U);
CHECK(query["peers"].get<json::array>()[0U].get<json::object>()["peerPassword"].get<bool>());
sendRequest(port, HTTP_GET, FNE_GET_PEER_COMMIT);
const std::string committedContents = readFileContents(aclFile.path);
CHECK(committedContents != initialContents);
CHECK(committedContents.find("9001") != std::string::npos);
lookups::PeerListLookup persisted(aclFile.path, 0U, true, false);
REQUIRE(persisted.read());
CHECK(persisted.find(9001U).peerAlias() == "REST peer");
json::object remove = json::object();
remove["peerId"] = json::value((double)9001U);
sendRequest(port, HTTP_PUT, FNE_PUT_PEER_DELETE, remove);
CHECK(readFileContents(aclFile.path) == committedContents);
sendRequest(port, HTTP_GET, FNE_GET_PEER_COMMIT);
const std::string deletedContents = readFileContents(aclFile.path);
CHECK(deletedContents != committedContents);
CHECK(deletedContents.find("9001") == std::string::npos);
CHECK(lookup.find(9001U).peerDefault());
}
TEST_CASE("FNE REST talkgroup endpoints query, persist, and delete rules", "[fne][restapi][tg]")
{
ScopedACLFile rulesFile;
lookups::TalkgroupRulesLookup lookup(rulesFile.path, 0U, true, false);
const std::string initialContents = readFileContents(rulesFile.path);
const uint16_t port = reserveLoopbackPort();
ScopedRESTAPI server(port, nullptr, nullptr, &lookup);
sendRequest(port, HTTP_PUT, FNE_PUT_TGID_ADD, talkgroupPayload(3100U, 1U, "REST TG"));
CHECK(readFileContents(rulesFile.path) == initialContents);
const lookups::TalkgroupRuleGroupVoice added = lookup.find(3100U, 1U);
REQUIRE_FALSE(added.isInvalid());
CHECK(added.name() == "REST TG");
CHECK(added.config().active());
json::object query = sendRequest(port, HTTP_GET, FNE_GET_TGID_QUERY);
REQUIRE(query["tgs"].get<json::array>().size() == 1U);
sendRequest(port, HTTP_GET, FNE_GET_TGID_COMMIT);
const std::string committedContents = readFileContents(rulesFile.path);
CHECK(committedContents != initialContents);
CHECK(committedContents.find("3100") != std::string::npos);
lookups::TalkgroupRulesLookup persisted(rulesFile.path, 0U, true, false);
REQUIRE(persisted.read());
CHECK_FALSE(persisted.find(3100U, 1U).isInvalid());
json::object remove = json::object();
remove["tgid"] = json::value((double)3100U);
remove["slot"] = json::value((double)1U);
sendRequest(port, HTTP_PUT, FNE_PUT_TGID_DELETE, remove);
CHECK(readFileContents(rulesFile.path) == committedContents);
sendRequest(port, HTTP_GET, FNE_GET_TGID_COMMIT);
const std::string deletedContents = readFileContents(rulesFile.path);
CHECK(deletedContents != committedContents);
CHECK(deletedContents.find("3100") == std::string::npos);
CHECK(lookup.find(3100U, 1U).isInvalid());
}
TEST_CASE("FNE REST adjacent-map endpoints query, persist, and delete entries", "[fne][restapi][adjmap]")
{
ScopedACLFile mapFile;
lookups::AdjSiteMapLookup lookup(mapFile.path, 0U);
const std::string initialContents = readFileContents(mapFile.path);
const uint16_t port = reserveLoopbackPort();
ScopedRESTAPI server(port, nullptr, nullptr, nullptr, nullptr, &lookup);
json::object add = json::object();
add["peerId"] = json::value((double)100U);
json::array neighbors = json::array();
neighbors.push_back(json::value((double)101U));
neighbors.push_back(json::value((double)102U));
add["neighbors"].set<json::array>(neighbors);
sendRequest(port, HTTP_PUT, FNE_PUT_ADJ_MAP_ADD, add);
CHECK(readFileContents(mapFile.path) == initialContents);
CHECK(lookup.find(100U).neighbors() == std::vector<uint32_t> { 101U, 102U });
json::object query = sendRequest(port, HTTP_GET, FNE_GET_ADJ_MAP_LIST);
REQUIRE(query["peers"].get<json::array>().size() == 1U);
sendRequest(port, HTTP_GET, FNE_GET_ADJ_MAP_COMMIT);
const std::string committedContents = readFileContents(mapFile.path);
CHECK(committedContents != initialContents);
CHECK(committedContents.find("100") != std::string::npos);
lookups::AdjSiteMapLookup persisted(mapFile.path, 0U);
REQUIRE(persisted.read());
CHECK(persisted.find(100U).neighbors() == std::vector<uint32_t> { 101U, 102U });
json::object remove = json::object();
remove["peerId"] = json::value((double)100U);
sendRequest(port, HTTP_PUT, FNE_PUT_ADJ_MAP_DELETE, remove);
CHECK(readFileContents(mapFile.path) == committedContents);
sendRequest(port, HTTP_GET, FNE_GET_ADJ_MAP_COMMIT);
const std::string deletedContents = readFileContents(mapFile.path);
CHECK(deletedContents != committedContents);
CHECK(deletedContents.find("100") == std::string::npos);
CHECK(lookup.find(100U).neighbors().empty());
}
TEST_CASE("FNE REST reload endpoints refresh their configured lookup tables", "[fne][restapi][reload]")
{
ScopedACLFile ridFile;
ScopedACLFile peerFile;
ScopedACLFile tgFile;
lookups::RadioIdLookup rids(ridFile.path, 0U, true);
lookups::PeerListLookup peers(peerFile.path, 0U, true, false);
lookups::TalkgroupRulesLookup tgs(tgFile.path, 0U, true, false);
rids.addEntry(1001U, true, "persisted RID");
rids.commit(true);
lookups::PeerId peer(2001U, "persisted peer", "secret", false);
peers.addEntry(2001U, peer);
peers.commit(true);
const uint16_t port = reserveLoopbackPort();
ScopedRESTAPI server(port, &rids, nullptr, &tgs, &peers);
sendRequest(port, HTTP_PUT, FNE_PUT_TGID_ADD, talkgroupPayload(3001U, 1U, "persisted TG"));
sendRequest(port, HTTP_GET, FNE_GET_TGID_COMMIT);
rids.addEntry(1002U, true, "memory only");
peers.addEntry(2002U, lookups::PeerId(2002U, "memory only", "", false));
sendRequest(port, HTTP_PUT, FNE_PUT_TGID_ADD, talkgroupPayload(3002U, 1U, "memory only"));
sendRequest(port, HTTP_GET, FNE_GET_RELOAD_RIDS);
sendRequest(port, HTTP_GET, FNE_GET_RELOAD_PEERLIST);
sendRequest(port, HTTP_GET, FNE_GET_RELOAD_TGS);
CHECK_FALSE(rids.find(1001U).radioDefault());
CHECK(rids.find(1002U).radioDefault());
CHECK_FALSE(peers.find(2001U).peerDefault());
CHECK(peers.find(2002U).peerDefault());
CHECK_FALSE(tgs.find(3001U, 1U).isInvalid());
CHECK(tgs.find(3002U, 1U).isInvalid());
}

Powered by TurnKey Linux.