// 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 #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 #include #include #include #include #include #include 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(file)), std::istreambuf_iterator()); } /** * @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() == 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(tgid); source["slot"].set(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()); config["exclusion"].set(json::array()); config["rewrite"].set(json::array()); config["always"].set(json::array()); config["preferred"].set(json::array()); config["permittedRids"].set(json::array()); json::object tg = json::object(); tg["name"].set(name); tg["alias"].set(name + " alias"); tg["source"].set(source); tg["config"].set(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("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(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 { 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 { 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("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(); REQUIRE(aliases.size() == 1U); json::object queriedAlias = aliases[0U].get(); CHECK(queriedAlias["id"].get() == TEST_RID); CHECK(queriedAlias["alias"].get() == "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("REST peer"); add["peerPassword"].set("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().size() == 1U); CHECK(query["peers"].get()[0U].get()["peerPassword"].get()); 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().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(neighbors); sendRequest(port, HTTP_PUT, FNE_PUT_ADJ_MAP_ADD, add); CHECK(readFileContents(mapFile.path) == initialContents); CHECK(lookup.find(100U).neighbors() == std::vector { 101U, 102U }); json::object query = sendRequest(port, HTTP_GET, FNE_GET_ADJ_MAP_LIST); REQUIRE(query["peers"].get().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 { 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()); }