feat(web-ui): add browse feature to find directories/executables/files (#4848)

This commit is contained in:
David Lane
2026-03-13 22:55:04 -04:00
committed by GitHub
parent b3f0e2370c
commit e836354e5a
7 changed files with 1113 additions and 10 deletions
+3
View File
@@ -49,6 +49,9 @@ curl -u user:pass -H "X-CSRF-Token: your_token_here" \
## DELETE /api/apps/{index}
@copydoc confighttp::deleteApp()
## GET /api/browse
@copydoc confighttp::browseDirectory()
## GET /api/clients/list
@copydoc confighttp::getClients()
+188
View File
@@ -1462,6 +1462,193 @@ namespace confighttp {
send_response(response, output_tree);
}
/**
* @brief Checks whether a directory entry qualifies as an executable file.
* @param entry The directory entry to check.
* @param status The cached file status for the entry.
* @return True if the file should be included in an executable-type listing.
*/
bool is_browsable_executable([[maybe_unused]] const fs::directory_entry &entry, [[maybe_unused]] const fs::file_status &status) {
#ifdef _WIN32
auto ext = entry.path().extension().string();
boost::algorithm::to_lower(ext);
return ext == ".exe" || ext == ".bat" || ext == ".cmd" || ext == ".com" || ext == ".ps1";
#else
const auto perms = status.permissions();
return (perms & fs::perms::owner_exec) != fs::perms::none ||
(perms & fs::perms::group_exec) != fs::perms::none ||
(perms & fs::perms::others_exec) != fs::perms::none;
#endif
}
#ifdef _WIN32
/**
* @brief Builds a JSON array of available Windows drive letters.
* @return JSON array of drive-letter entries.
*/
nlohmann::json get_windows_drives() {
nlohmann::json entries = nlohmann::json::array();
const DWORD drives = GetLogicalDrives();
for (int i = 0; i < 26; ++i) {
if (drives & (1 << i)) {
const auto drive_letter = static_cast<char>('A' + i);
const auto drive_path = std::string(1, drive_letter) + ":\\";
nlohmann::json entry;
entry["name"] = drive_path;
entry["type"] = "directory";
entry["path"] = drive_path;
entries.push_back(entry);
}
}
return entries;
}
#endif
/**
* @brief Lists, filters, and sorts the entries of a directory for the browse API.
* @param dir_path The directory to list.
* @param type_str Filter type: "directory", "executable", "file", or "any".
* @return Sorted JSON array of entry objects with name/type/path fields.
*/
nlohmann::json build_browse_entries(const fs::path &dir_path, const std::string &type_str) {
nlohmann::json entries = nlohmann::json::array();
std::error_code iter_ec;
for (auto it = fs::directory_iterator(dir_path, fs::directory_options::skip_permission_denied, iter_ec);
!iter_ec && it != fs::directory_iterator();
it.increment(iter_ec)) {
try {
const auto status = it->status();
const bool is_dir = fs::is_directory(status);
if (const bool is_regular = fs::is_regular_file(status); !is_dir && !is_regular) {
continue;
}
// Apply type filter (directories are always included for navigation)
if (type_str == "directory" && !is_dir) {
continue;
}
if (type_str == "executable" && !is_dir && !is_browsable_executable(*it, status)) {
continue;
}
nlohmann::json file_entry;
file_entry["name"] = it->path().filename().string();
file_entry["path"] = it->path().string();
file_entry["type"] = is_dir ? "directory" : "file";
entries.push_back(file_entry);
} catch (const fs::filesystem_error &e) {
BOOST_LOG(debug) << "BrowseDirectory: skipping entry due to error: "sv << e.what();
}
}
if (iter_ec) {
BOOST_LOG(debug) << "BrowseDirectory: directory iteration error: "sv << iter_ec.message();
}
// Sort: directories first, then files; both case-insensitively alphabetical
std::sort(entries.begin(), entries.end(), [](const nlohmann::json &a, const nlohmann::json &b) {
const bool a_dir = (a["type"] == "directory");
if (const bool b_dir = (b["type"] == "directory"); a_dir != b_dir) {
return a_dir && !b_dir;
}
auto a_name = a["name"].get<std::string>();
auto b_name = b["name"].get<std::string>();
boost::algorithm::to_lower(a_name);
boost::algorithm::to_lower(b_name);
return a_name < b_name;
});
return entries;
}
/**
* @brief Browse the server filesystem.
* @param response The HTTP response object.
* @param request The HTTP request object.
* @note On Windows, an empty or root path returns the list of available drive letters.
* @note On non-Windows, an empty path defaults to the filesystem root ("/").
*
* @api_examples{/api/browse?path=/home/user&type=directory| GET| null}
*/
void browseDirectory(const resp_https_t &response, const req_https_t &request) {
if (!authenticate(response, request)) {
return;
}
print_req(request);
try {
const auto query_params = request->parse_query_string();
std::string path_str;
if (const auto path_it = query_params.find("path"); path_it != query_params.end()) {
path_str = path_it->second;
}
std::string type_str = "any";
if (const auto type_it = query_params.find("type"); type_it != query_params.end() && !type_it->second.empty()) {
type_str = type_it->second;
}
nlohmann::json output_tree;
#ifdef _WIN32
// On Windows with an empty or root path, return the list of available drive letters
if (path_str.empty() || path_str == "/" || path_str == "\\") {
output_tree["path"] = "";
output_tree["parent"] = "";
output_tree["entries"] = get_windows_drives();
send_response(response, output_tree);
return;
}
#else
// On non-Windows, default an empty path to the filesystem root
if (path_str.empty()) {
path_str = "/";
}
#endif
// Normalize the path
fs::path dir_path = fs::weakly_canonical(fs::path(path_str));
// If the path points to a file, use its parent directory
std::error_code ec;
if (fs::is_regular_file(dir_path, ec)) {
dir_path = dir_path.parent_path();
}
// If the path doesn't exist, try the parent
if (!fs::exists(dir_path, ec)) {
dir_path = dir_path.parent_path();
}
if (!fs::is_directory(dir_path, ec)) {
bad_request(response, request, "Path is not a directory");
return;
}
output_tree["path"] = dir_path.string();
// Determine the parent path for the "Up" navigation
const fs::path parent = dir_path.parent_path();
#ifdef _WIN32
// At a drive root (e.g., C:\) the parent equals itself; signal the drive list with an empty string
output_tree["parent"] = (parent == dir_path) ? "" : parent.string();
#else
output_tree["parent"] = parent.string();
#endif
output_tree["entries"] = build_browse_entries(dir_path, type_str);
send_response(response, output_tree);
} catch (const fs::filesystem_error &e) {
BOOST_LOG(warning) << "BrowseDirectory: "sv << e.what();
bad_request(response, request, e.what());
}
}
void start() {
platf::set_thread_name("confighttp");
const auto shutdown_event = mail::man->event<bool>(mail::shutdown);
@@ -1505,6 +1692,7 @@ namespace confighttp {
server.resource["^/welcome/?$"]["GET"] = page_handler("welcome.html", false, true);
// rest api
server.resource["^/api/browse$"]["GET"] = browseDirectory;
server.resource["^/api/apps$"]["GET"] = getApps;
server.resource["^/api/apps$"]["POST"] = saveApp;
server.resource["^/api/apps/([0-9]+)$"]["DELETE"] = deleteApp;
+27
View File
@@ -5,6 +5,7 @@
#pragma once
// standard includes
#include <filesystem>
#include <memory>
#include <string>
@@ -42,8 +43,34 @@ namespace confighttp {
bool check_app_index(const resp_https_t &response, const req_https_t &request, int index);
void getPage(const resp_https_t &response, const req_https_t &request, const char *html_file, bool require_auth = true, bool redirect_if_username = false);
void getAsset(const resp_https_t &response, const req_https_t &request);
void browseDirectory(const resp_https_t &response, const req_https_t &request);
void getLocale(const resp_https_t &response, const req_https_t &request);
void getCSRFToken(const resp_https_t &response, const req_https_t &request);
// Browse helper functions (also exposed for unit testing)
/**
* @brief Checks whether a directory entry qualifies as an executable file.
* @param entry The directory entry to check.
* @param status The cached file status for the entry.
* @return True if the file should be included in an executable-type listing.
*/
bool is_browsable_executable(const std::filesystem::directory_entry &entry, const std::filesystem::file_status &status);
/**
* @brief Lists, filters, and sorts the entries of a directory for the browse API.
* @param dir_path The directory to list.
* @param type_str Filter type: "directory", "executable", "file", or "any".
* @return Sorted JSON array of entry objects with name/type/path fields.
*/
nlohmann::json build_browse_entries(const std::filesystem::path &dir_path, const std::string &type_str);
#ifdef _WIN32
/**
* @brief Builds a JSON array of available Windows drive letters.
* @return JSON array of drive-letter entries.
*/
nlohmann::json get_windows_drives();
#endif
} // namespace confighttp
// mime types map
+209 -8
View File
@@ -73,8 +73,14 @@
<!-- output -->
<div class="mb-3">
<label for="appOutput" class="form-label">{{ $t('apps.output_name') }}</label>
<input type="text" class="form-control monospace" id="appOutput" aria-describedby="appOutputHelp"
v-model="editForm.output" />
<div class="input-group">
<input type="text" class="form-control monospace" id="appOutput" aria-describedby="appOutputHelp"
v-model="editForm.output" />
<button class="btn btn-secondary" type="button"
@click="browseFor('any', 'file_browser.select_file', editForm.output, v => editForm.output = v)">
<folder-open :size="18" class="icon"></folder-open>
</button>
</div>
<div id="appOutputHelp" class="form-text">{{ $t('apps.output_desc') }}</div>
</div>
<!-- prep-cmd -->
@@ -116,10 +122,20 @@
<tbody>
<tr v-for="(c, i) in editForm['prep-cmd']">
<td>
<input type="text" class="form-control monospace" v-model="c.do" />
<div class="input-group">
<input type="text" class="form-control monospace" v-model="c.do" />
<button class="btn btn-secondary btn-sm" type="button" @click="browsePrep(i, 'do')">
<folder-open :size="14" class="icon"></folder-open>
</button>
</div>
</td>
<td>
<input type="text" class="form-control monospace" v-model="c.undo" />
<div class="input-group">
<input type="text" class="form-control monospace" v-model="c.undo" />
<button class="btn btn-secondary btn-sm" type="button" @click="browsePrep(i, 'undo')">
<folder-open :size="14" class="icon"></folder-open>
</button>
</div>
</td>
<td v-if="platform === 'windows'" class="align-middle">
<Checkbox :id="'prep-cmd-admin-' + i"
@@ -145,6 +161,9 @@
<label for="appName" class="form-label">{{ $t('apps.detached_cmds') }}</label>
<div v-for="(c,i) in editForm.detached" class="d-flex justify-content-between align-items-center my-2">
<input type="text" v-model="editForm.detached[i]" class="form-control monospace">
<button class="btn btn-secondary btn-sm ms-2" @click="browseDetached(i)">
<folder-open :size="14" class="icon"></folder-open>
</button>
<button class="btn btn-danger btn-sm ms-2" @click="editForm.detached.splice(i,1)">
<trash-2 :size="16" class="icon"></trash-2>
</button>
@@ -166,8 +185,14 @@
<!-- command -->
<div class="mb-3">
<label for="appCmd" class="form-label">{{ $t('apps.cmd') }}</label>
<input type="text" class="form-control monospace" id="appCmd" aria-describedby="appCmdHelp"
v-model="editForm.cmd" />
<div class="input-group">
<input type="text" class="form-control monospace" id="appCmd" aria-describedby="appCmdHelp"
v-model="editForm.cmd" />
<button class="btn btn-secondary" type="button"
@click="browseFor('executable', 'file_browser.select_executable', editForm.cmd, v => editForm.cmd = v)">
<folder-open :size="18" class="icon"></folder-open>
</button>
</div>
<div id="appCmdHelp" class="form-text">
{{ $t('apps.cmd_desc') }}<br>
<b>{{ $t('_common.note') }}</b> {{ $t('apps.cmd_note') }}
@@ -176,8 +201,14 @@
<!-- working dir -->
<div class="mb-3">
<label for="appWorkingDir" class="form-label">{{ $t('apps.working_dir') }}</label>
<input type="text" class="form-control monospace" id="appWorkingDir" aria-describedby="appWorkingDirHelp"
v-model="editForm['working-dir']" />
<div class="input-group">
<input type="text" class="form-control monospace" id="appWorkingDir" aria-describedby="appWorkingDirHelp"
v-model="editForm['working-dir']" />
<button class="btn btn-secondary" type="button"
@click="browseFor('directory', 'file_browser.select_directory', editForm['working-dir'], v => editForm['working-dir'] = v)">
<folder-open :size="18" class="icon"></folder-open>
</button>
</div>
<div id="appWorkingDirHelp" class="form-text">{{ $t('apps.working_dir_desc') }}</div>
</div>
<!-- elevation -->
@@ -217,6 +248,10 @@
<div class="input-group">
<input type="text" class="form-control monospace" id="appImagePath" aria-describedby="appImagePathHelp"
v-model="editForm['image-path']" />
<button class="btn btn-secondary" type="button"
@click="browseFor('file', 'file_browser.select_file', editForm['image-path'], v => editForm['image-path'] = v)">
<folder-open :size="18" class="icon"></folder-open>
</button>
<button class="btn btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#coverFinderModal"
@click="showCoverFinder">
<search :size="18" class="icon"></search>
@@ -371,6 +406,74 @@
{{ $t('apps.add_new') }}
</button>
</div>
<!-- Shared file browser modal -->
<div class="modal fade" ref="fileBrowserModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable modal-fullscreen-md-down">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ fileBrowserTitle || $t('file_browser.title') }}</h5>
<button type="button" class="btn-close" @click="fileBrowserClose" :aria-label="$t('_common.close')"></button>
</div>
<div class="modal-body">
<!-- Path input -->
<div class="input-group mb-2">
<input type="text" class="form-control monospace" v-model="fileBrowserTypedPath"
@input="fileBrowserOnTypedInput" @keyup.enter="fileBrowserNavigate(fileBrowserTypedPath)" />
<button class="btn btn-secondary" type="button" @click="fileBrowserNavigate(fileBrowserTypedPath)">
<arrow-right :size="16" class="icon"></arrow-right>
</button>
</div>
<!-- Up button -->
<div class="mb-2">
<button class="btn btn-sm btn-outline-secondary" type="button"
:disabled="fileBrowserLoading || fileBrowserParentPath === fileBrowserCurrentPath"
@click="fileBrowserNavigateUp">
<folder-up :size="16" class="icon me-1"></folder-up>
{{ $t('file_browser.up') }}
</button>
</div>
<!-- Error -->
<div v-if="fileBrowserError" class="alert alert-danger py-2 small">{{ fileBrowserError }}</div>
<!-- Loading -->
<div v-if="fileBrowserLoading" class="text-center py-3">
<output class="spinner-border spinner-border-sm">
<span class="visually-hidden">{{ $t('_common.loading') }}</span>
</output>
</div>
<!-- Entries -->
<div v-else class="list-group" style="max-height: 400px; overflow-y: auto;">
<div v-if="fileBrowserEntries.length === 0" class="list-group-item text-muted text-center">
{{ $t('file_browser.empty') }}
</div>
<button v-for="entry in fileBrowserEntries" :key="entry.path" type="button"
class="list-group-item list-group-item-action d-flex align-items-center py-1"
:class="{ active: fileBrowserSelectedPath === entry.path }"
@click="fileBrowserSelectEntry(entry)" @dblclick="fileBrowserActivateEntry(entry)">
<hard-drive v-if="!fileBrowserCurrentPath && entry.type === 'directory'" :size="16" class="icon me-2 flex-shrink-0"></hard-drive>
<folder v-else-if="entry.type === 'directory'" :size="16" class="icon me-2 flex-shrink-0 text-warning"></folder>
<file-text v-else :size="16" class="icon me-2 flex-shrink-0"></file-text>
<span class="text-truncate">{{ entry.name }}</span>
</button>
</div>
</div>
<div class="modal-footer flex-wrap gap-2">
<div class="flex-grow-1 text-muted small text-truncate" v-if="fileBrowserSelectedPath">
<code>{{ fileBrowserSelectedPath }}</code>
</div>
<button type="button" class="btn btn-secondary" @click="fileBrowserClose">
<x :size="16" class="icon me-1"></x>
{{ $t('_common.cancel') }}
</button>
<button type="button" class="btn btn-primary" @click="fileBrowserConfirm"
:disabled="!fileBrowserSelectedPath && !fileBrowserTypedPath">
<check :size="16" class="icon me-1"></check>
{{ $t('file_browser.select') }}
</button>
</div>
</div>
</div>
</div>
</div>
</body>
<script type="module">
@@ -380,8 +483,14 @@
import Checkbox from './Checkbox.vue'
import { Modal } from 'bootstrap/dist/js/bootstrap'
import {
ArrowRight,
Check,
Edit,
FileText,
Folder,
FolderOpen,
FolderUp,
HardDrive,
LayersPlus,
Play,
Plus,
@@ -398,8 +507,14 @@
components: {
Navbar,
Checkbox,
ArrowRight,
Check,
Edit,
FileText,
Folder,
FolderOpen,
FolderUp,
HardDrive,
LayersPlus,
Play,
Plus,
@@ -422,6 +537,16 @@
coverCandidates: [],
coverSearchQuery: "",
platform: "",
fileBrowserType: "any",
fileBrowserTitle: "",
fileBrowserCallback: null,
fileBrowserCurrentPath: "",
fileBrowserParentPath: "",
fileBrowserEntries: [],
fileBrowserLoading: false,
fileBrowserError: "",
fileBrowserSelectedPath: "",
fileBrowserTypedPath: "",
};
},
created() {
@@ -605,6 +730,82 @@
})
.finally(() => this.coverFinderBusy = false);
},
browseFor(type, titleKey, startPath, callback) {
this.fileBrowserType = type;
this.fileBrowserTitle = this.$t(titleKey);
this.fileBrowserCallback = callback;
this.fileBrowserSelectedPath = startPath || '';
this.fileBrowserTypedPath = startPath || '';
this.fileBrowserError = '';
this.fileBrowserNavigate(startPath || '');
Modal.getOrCreateInstance(this.$refs.fileBrowserModal).show();
},
fileBrowserClose() {
const modal = Modal.getInstance(this.$refs.fileBrowserModal);
if (modal) modal.hide();
},
fileBrowserConfirm() {
const path = this.fileBrowserSelectedPath || this.fileBrowserTypedPath;
if (path) {
if (this.fileBrowserCallback) {
this.fileBrowserCallback(path);
this.fileBrowserCallback = null;
}
this.fileBrowserClose();
}
},
fileBrowserNavigate(path) {
this.fileBrowserLoading = true;
this.fileBrowserError = '';
const params = new URLSearchParams({ type: this.fileBrowserType });
if (path) params.set('path', path);
fetch(`./api/browse?${params.toString()}`)
.then(r => r.ok ? r.json() : r.json().then(e => { throw new Error(e.error || 'Browse failed'); }))
.then(data => {
this.fileBrowserCurrentPath = data.path ?? '';
this.fileBrowserParentPath = data.parent ?? '';
this.fileBrowserEntries = data.entries ?? [];
this.fileBrowserTypedPath = data.path ?? '';
this.fileBrowserSelectedPath = this.fileBrowserType === 'directory' ? (data.path ?? '') : '';
})
.catch(err => { this.fileBrowserError = err.message; })
.finally(() => { this.fileBrowserLoading = false; });
},
fileBrowserNavigateUp() {
this.fileBrowserNavigate(this.fileBrowserParentPath);
},
fileBrowserSelectEntry(entry) {
if (entry.type === 'directory') {
this.fileBrowserNavigate(entry.path);
} else {
this.fileBrowserSelectedPath = entry.path;
this.fileBrowserTypedPath = entry.path;
}
},
fileBrowserActivateEntry(entry) {
if (entry.type === 'directory') {
this.fileBrowserNavigate(entry.path);
} else {
this.fileBrowserSelectedPath = entry.path;
this.fileBrowserTypedPath = entry.path;
this.fileBrowserConfirm();
}
},
fileBrowserOnTypedInput() {
this.fileBrowserSelectedPath = this.fileBrowserTypedPath;
},
browsePrep(index, field) {
const current = this.editForm['prep-cmd'][index][field] || '';
this.browseFor('executable', 'file_browser.select_executable', current, (path) => {
this.editForm['prep-cmd'][index][field] = path;
});
},
browseDetached(index) {
const current = this.editForm.detached[index] || '';
this.browseFor('executable', 'file_browser.select_executable', current, (path) => {
this.editForm.detached[index] = path;
});
},
save() {
this.editForm["image-path"] = this.editForm["image-path"].toString().replace(/"/g, '');
fetch("./api/apps", {
@@ -5,6 +5,7 @@
"auto": "Automatic",
"autodetect": "Autodetect (recommended)",
"beta": "(beta)",
"browse": "Browse",
"cancel": "Cancel",
"close": "Close",
"disabled": "Disabled",
@@ -510,6 +511,16 @@
"title": "Featured Apps",
"website": "Website"
},
"file_browser": {
"empty": "No items to display",
"root": "Root",
"select": "Select",
"select_directory": "Select Directory",
"select_executable": "Select Executable",
"select_file": "Select File",
"title": "Browse",
"up": "Up"
},
"welcome": {
"confirm_password": "Confirm password",
"create_creds": "Before Getting Started, we need you to make a new username and password for accessing the Web UI.",
+674 -1
View File
@@ -131,7 +131,7 @@ protected:
};
// Create test web directory in temp
test_web_dir = std::filesystem::temp_directory_path() / "sunshine_test_confighttp";
test_web_dir = std::filesystem::temp_directory_path() / "sunshine_test_confighttp"; // NOSONAR(cpp:S5443) - safe for tests
std::filesystem::create_directories(test_web_dir / "web");
// Create test HTML file in WEB_DIR, creating parent directories with proper permissions
@@ -300,6 +300,14 @@ protected:
confighttp::getLocale(response, request);
};
// Add a route to test browseDirectory
server->resource["^/browse-test$"]["GET"] = [](
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Response> &response,
const std::shared_ptr<SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Request> &request
) {
confighttp::browseDirectory(response, request);
};
// Start server
server_thread = std::thread([this]() { // NOSONAR(cpp:S6168) - jthread not available on FreeBSD 14.3 libc++
server->start([this](const unsigned short assigned_port) {
@@ -728,3 +736,668 @@ TEST_F(ConfigHttpTest, GetLocaleReturnsJson) {
ASSERT_TRUE(body.find("\"status\":true") != std::string::npos || body.find("\"status\": true") != std::string::npos);
ASSERT_TRUE(body.find("\"locale\":\"en\"") != std::string::npos || body.find("\"locale\": \"en\"") != std::string::npos);
}
/**
* @brief Test fixture for confighttp::browseDirectory tests.
*
* Creates a known directory structure in the system temp directory so that
* the browse endpoint can be exercised with predictable contents.
*
* Layout:
* sunshine_browse_test/
* ├── subdir_a/
* ├── subdir_b/
* ├── file_alpha.txt
* ├── file_beta.txt
* └── test_exec[.exe] (executable file)
*/
class BrowseDirectoryTest: public ConfigHttpTest { // NOSONAR(cpp:S3656) - protected members are intentional for test fixture subclassing
protected:
std::filesystem::path browse_test_dir;
void SetUp() override {
ConfigHttpTest::SetUp();
browse_test_dir = std::filesystem::temp_directory_path() / "sunshine_browse_test"; // NOSONAR(cpp:S5443) - safe for tests
// Remove any leftover directory from a previous interrupted run
if (std::filesystem::exists(browse_test_dir)) {
std::filesystem::remove_all(browse_test_dir);
}
std::filesystem::create_directories(browse_test_dir / "subdir_a");
std::filesystem::create_directories(browse_test_dir / "subdir_b");
std::ofstream(browse_test_dir / "file_alpha.txt") << "alpha";
std::ofstream(browse_test_dir / "file_beta.txt") << "beta";
#ifdef _WIN32
std::ofstream(browse_test_dir / "test_exec.exe") << "fake exe";
#else
const auto exec_file = browse_test_dir / "test_exec";
std::ofstream(exec_file) << "#!/bin/sh\necho hello";
std::filesystem::permissions(
exec_file,
std::filesystem::perms::owner_read | std::filesystem::perms::owner_write | std::filesystem::perms::owner_exec,
std::filesystem::perm_options::replace
);
#endif
}
void TearDown() override {
if (std::filesystem::exists(browse_test_dir)) {
std::filesystem::remove_all(browse_test_dir);
}
ConfigHttpTest::TearDown();
}
/**
* @brief URL-encodes a single query-parameter value.
*
* All characters except unreserved ones (RFC 3986) are percent-encoded so
* that slashes, backslashes, colons, etc. in filesystem paths are
* transmitted correctly.
*/
static std::string url_encode_param(const std::string &str) {
std::string encoded;
for (const unsigned char c : str) {
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
encoded += static_cast<char>(c);
} else {
encoded += std::format("%{:02X}", static_cast<int>(c));
}
}
return encoded;
}
/**
* @brief Builds the /browse-test URL with optional path and type query params.
*/
std::string browse_url(const std::string &path = "", const std::string &type = "") const {
std::string url = "/browse-test";
std::string sep = "?";
if (!path.empty()) {
url += sep + "path=" + url_encode_param(path);
sep = "&";
}
if (!type.empty()) {
url += sep + "type=" + type;
}
return url;
}
/**
* @brief Helper: locate an entry by name in the JSON entries array.
*/
static nlohmann::json::const_iterator find_entry(const nlohmann::json &entries, const std::string &name) {
return std::ranges::find_if(entries, [&name](const nlohmann::json &e) {
return e.at("name").get<std::string>() == name;
});
}
};
// Test: browseDirectory requires authentication
TEST_F(BrowseDirectoryTest, BrowseRequiresAuthentication) {
const auto response = client->request("GET", browse_url(browse_test_dir.string()));
ASSERT_EQ(response->status_code, "401 Unauthorized");
}
// Test: browseDirectory returns 200 with valid JSON for a real directory
TEST_F(BrowseDirectoryTest, BrowseListsValidDirectory) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
const auto response = client->request("GET", browse_url(browse_test_dir.string()), "", headers);
ASSERT_EQ(response->status_code, "200 OK");
const auto content_type = response->header.find("Content-Type");
ASSERT_NE(content_type, response->header.end());
ASSERT_TRUE(content_type->second.find("application/json") != std::string::npos);
assert_security_headers(response);
const nlohmann::json json = nlohmann::json::parse(response->content.string());
ASSERT_TRUE(json.contains("path"));
ASSERT_TRUE(json.contains("parent"));
ASSERT_TRUE(json.contains("entries"));
ASSERT_TRUE(json["entries"].is_array());
}
// Test: returned 'path' field matches the requested directory
TEST_F(BrowseDirectoryTest, BrowseResponsePathMatchesRequest) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
const auto response = client->request("GET", browse_url(browse_test_dir.string()), "", headers);
ASSERT_EQ(response->status_code, "200 OK");
const nlohmann::json json = nlohmann::json::parse(response->content.string());
const std::filesystem::path returned = std::filesystem::weakly_canonical(json["path"].get<std::string>());
const std::filesystem::path expected = std::filesystem::weakly_canonical(browse_test_dir);
ASSERT_EQ(returned, expected);
}
// Test: returned 'parent' field is the parent of 'path'
TEST_F(BrowseDirectoryTest, BrowseResponseParentIsCorrect) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
const auto response = client->request("GET", browse_url(browse_test_dir.string()), "", headers);
ASSERT_EQ(response->status_code, "200 OK");
const nlohmann::json json = nlohmann::json::parse(response->content.string());
const std::filesystem::path returned_path(json["path"].get<std::string>());
const std::filesystem::path returned_parent(json["parent"].get<std::string>());
ASSERT_EQ(returned_parent, returned_path.parent_path());
}
// Test: entries contain the expected subdirectories and files with correct types
TEST_F(BrowseDirectoryTest, BrowseResponseContainsExpectedEntries) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
const auto response = client->request("GET", browse_url(browse_test_dir.string()), "", headers);
ASSERT_EQ(response->status_code, "200 OK");
const nlohmann::json json = nlohmann::json::parse(response->content.string());
const auto &entries = json["entries"];
const auto subdir_a = find_entry(entries, "subdir_a");
ASSERT_NE(subdir_a, entries.end());
ASSERT_EQ((*subdir_a)["type"].get<std::string>(), "directory");
const auto subdir_b = find_entry(entries, "subdir_b");
ASSERT_NE(subdir_b, entries.end());
ASSERT_EQ((*subdir_b)["type"].get<std::string>(), "directory");
const auto file_alpha = find_entry(entries, "file_alpha.txt");
ASSERT_NE(file_alpha, entries.end());
ASSERT_EQ((*file_alpha)["type"].get<std::string>(), "file");
const auto file_beta = find_entry(entries, "file_beta.txt");
ASSERT_NE(file_beta, entries.end());
ASSERT_EQ((*file_beta)["type"].get<std::string>(), "file");
}
// Test: every entry has non-empty 'name', 'type', and 'path' fields
TEST_F(BrowseDirectoryTest, BrowseEntryFieldsArePresent) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
const auto response = client->request("GET", browse_url(browse_test_dir.string()), "", headers);
ASSERT_EQ(response->status_code, "200 OK");
const nlohmann::json json = nlohmann::json::parse(response->content.string());
for (const auto &entry : json["entries"]) {
ASSERT_TRUE(entry.contains("name"));
ASSERT_TRUE(entry.contains("type"));
ASSERT_TRUE(entry.contains("path"));
ASSERT_FALSE(entry["name"].get<std::string>().empty());
ASSERT_FALSE(entry["path"].get<std::string>().empty());
const auto type = entry["type"].get<std::string>();
ASSERT_TRUE(type == "directory" || type == "file");
}
}
// Test: entries are sorted all directories appear before any file
TEST_F(BrowseDirectoryTest, BrowseEntriesSortedDirsFirst) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
const auto response = client->request("GET", browse_url(browse_test_dir.string()), "", headers);
ASSERT_EQ(response->status_code, "200 OK");
const nlohmann::json json = nlohmann::json::parse(response->content.string());
bool seen_file = false;
for (const auto &entry : json["entries"]) {
const std::string type = entry["type"].get<std::string>();
if (type == "file") {
seen_file = true;
} else if (type == "directory") {
ASSERT_FALSE(seen_file) << "Directory '" << entry["name"] << "' appears after a file in the listing";
}
}
}
// Test: entries within each group (dirs / files) are sorted case-insensitively
TEST_F(BrowseDirectoryTest, BrowseEntriesSortedAlphabeticallyWithinGroups) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
const auto response = client->request("GET", browse_url(browse_test_dir.string()), "", headers);
ASSERT_EQ(response->status_code, "200 OK");
const nlohmann::json json = nlohmann::json::parse(response->content.string());
std::string prev_dir;
std::string prev_file;
for (const auto &entry : json["entries"]) {
std::string name = entry["name"].get<std::string>();
std::ranges::transform(name, name.begin(), ::tolower);
if (entry["type"] == "directory") {
if (!prev_dir.empty()) {
ASSERT_LE(prev_dir, name) << "Directories are not in alphabetical order";
}
prev_dir = name;
} else {
if (!prev_file.empty()) {
ASSERT_LE(prev_file, name) << "Files are not in alphabetical order";
}
prev_file = name;
}
}
}
// Test: type=directory filter excludes files from the listing
TEST_F(BrowseDirectoryTest, BrowseTypeDirFilterExcludesFiles) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
const auto response = client->request("GET", browse_url(browse_test_dir.string(), "directory"), "", headers);
ASSERT_EQ(response->status_code, "200 OK");
const nlohmann::json json = nlohmann::json::parse(response->content.string());
const auto &entries = json["entries"];
for (const auto &entry : entries) {
ASSERT_EQ(entry["type"].get<std::string>(), "directory")
<< "Non-directory entry '" << entry["name"] << "' found with type=directory filter";
}
// Subdirectories must still be present
ASSERT_NE(find_entry(entries, "subdir_a"), entries.end());
ASSERT_NE(find_entry(entries, "subdir_b"), entries.end());
}
// Test: type=file returns both files and directories
TEST_F(BrowseDirectoryTest, BrowseTypeFileReturnsBoth) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
const auto response = client->request("GET", browse_url(browse_test_dir.string(), "file"), "", headers);
ASSERT_EQ(response->status_code, "200 OK");
const nlohmann::json json = nlohmann::json::parse(response->content.string());
const auto &entries = json["entries"];
const bool has_dir = std::ranges::any_of(entries, [](const nlohmann::json &e) {
return e["type"] == "directory";
});
const bool has_file = std::ranges::any_of(entries, [](const nlohmann::json &e) {
return e["type"] == "file";
});
ASSERT_TRUE(has_dir);
ASSERT_TRUE(has_file);
}
// Test: type=executable still includes directories for navigation
TEST_F(BrowseDirectoryTest, BrowseTypeExecutableIncludesDirs) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
const auto response = client->request("GET", browse_url(browse_test_dir.string(), "executable"), "", headers);
ASSERT_EQ(response->status_code, "200 OK");
const nlohmann::json json = nlohmann::json::parse(response->content.string());
const auto &entries = json["entries"];
const bool has_dir = std::ranges::any_of(entries, [](const nlohmann::json &e) {
return e["type"] == "directory";
});
ASSERT_TRUE(has_dir);
}
// Test: type=executable excludes plain (non-executable) files
TEST_F(BrowseDirectoryTest, BrowseTypeExecutableExcludesNonExecutableFiles) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
const auto response = client->request("GET", browse_url(browse_test_dir.string(), "executable"), "", headers);
ASSERT_EQ(response->status_code, "200 OK");
const nlohmann::json json = nlohmann::json::parse(response->content.string());
const auto &entries = json["entries"];
// file_alpha.txt and file_beta.txt have no execute permission / wrong extension
for (const auto &entry : entries) {
if (entry["type"] == "file") {
const std::string name = entry["name"].get<std::string>();
ASSERT_NE(name, "file_alpha.txt") << "Non-executable file included in type=executable listing";
ASSERT_NE(name, "file_beta.txt") << "Non-executable file included in type=executable listing";
}
}
}
// Test: type=executable includes the known executable file
TEST_F(BrowseDirectoryTest, BrowseTypeExecutableIncludesExecutableFile) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
const auto response = client->request("GET", browse_url(browse_test_dir.string(), "executable"), "", headers);
ASSERT_EQ(response->status_code, "200 OK");
const nlohmann::json json = nlohmann::json::parse(response->content.string());
const auto &entries = json["entries"];
#ifdef _WIN32
const std::string exec_name = "test_exec.exe";
#else
const std::string exec_name = "test_exec";
#endif
ASSERT_NE(find_entry(entries, exec_name), entries.end())
<< "Expected executable file '" << exec_name << "' not found with type=executable filter";
}
// Test: supplying a file path navigates to its parent directory
TEST_F(BrowseDirectoryTest, BrowseFilepathNavigatesToParentDirectory) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
const std::filesystem::path file_path = browse_test_dir / "file_alpha.txt";
const auto response = client->request("GET", browse_url(file_path.string()), "", headers);
ASSERT_EQ(response->status_code, "200 OK");
const nlohmann::json json = nlohmann::json::parse(response->content.string());
const std::filesystem::path returned = std::filesystem::weakly_canonical(json["path"].get<std::string>());
const std::filesystem::path expected = std::filesystem::weakly_canonical(browse_test_dir);
ASSERT_EQ(returned, expected);
}
// Test: a non-existent child path falls back to the existing parent directory
TEST_F(BrowseDirectoryTest, BrowseNonexistentChildPathFallsBackToParent) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
const std::filesystem::path nonexistent = browse_test_dir / "does_not_exist_xyz";
const auto response = client->request("GET", browse_url(nonexistent.string()), "", headers);
ASSERT_EQ(response->status_code, "200 OK");
const nlohmann::json json = nlohmann::json::parse(response->content.string());
const std::filesystem::path returned = std::filesystem::weakly_canonical(json["path"].get<std::string>());
const std::filesystem::path expected = std::filesystem::weakly_canonical(browse_test_dir);
ASSERT_EQ(returned, expected);
}
// Test: a path where both the target and its parent don't exist returns 400
TEST_F(BrowseDirectoryTest, BrowseTrulyNonexistentPathReturnsBadRequest) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
// Construct a deeply non-existent path (parent also doesn't exist)
const std::string nonexistent = "/sunshine_nonexistent_xyz_54321/also_nonexistent";
const auto response = client->request("GET", browse_url(nonexistent), "", headers);
ASSERT_EQ(response->status_code, "400 Bad Request");
}
// Test: omitting the path parameter returns a valid response (defaults to a browsable location)
TEST_F(BrowseDirectoryTest, BrowseEmptyPathReturnsValidResponse) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
const auto response = client->request("GET", "/browse-test", "", headers);
ASSERT_EQ(response->status_code, "200 OK");
const nlohmann::json json = nlohmann::json::parse(response->content.string());
ASSERT_TRUE(json.contains("path"));
ASSERT_TRUE(json.contains("parent"));
ASSERT_TRUE(json.contains("entries"));
ASSERT_TRUE(json["entries"].is_array());
}
#ifdef _WIN32
// Test (Windows): empty/root path returns the list of logical drive letters
TEST_F(BrowseDirectoryTest, BrowseWindowsEmptyPathReturnsDriveList) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
const auto response = client->request("GET", "/browse-test", "", headers);
ASSERT_EQ(response->status_code, "200 OK");
const nlohmann::json json = nlohmann::json::parse(response->content.string());
ASSERT_EQ(json["path"].get<std::string>(), "");
ASSERT_EQ(json["parent"].get<std::string>(), "");
ASSERT_GT(json["entries"].size(), 0u);
// Every entry must look like "X:\" a drive letter root
for (const auto &entry : json["entries"]) {
ASSERT_EQ(entry["type"].get<std::string>(), "directory");
const std::string name = entry["name"].get<std::string>();
ASSERT_EQ(name.size(), 3u) << "Drive entry name should be 3 chars, e.g. 'C:\\'";
ASSERT_TRUE(std::isalpha(static_cast<unsigned char>(name[0])));
ASSERT_EQ(name[1], ':');
ASSERT_EQ(name[2], '\\');
}
}
#else
// Test (Unix): browsing "/" returns path == "/" and parent == "/" (at root, parent == self)
TEST_F(BrowseDirectoryTest, BrowseUnixRootParentEqualsSelf) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
const auto response = client->request("GET", browse_url("/"), "", headers);
ASSERT_EQ(response->status_code, "200 OK");
const nlohmann::json json = nlohmann::json::parse(response->content.string());
const std::string path = json["path"].get<std::string>();
const std::string parent = json["parent"].get<std::string>();
ASSERT_EQ(path, "/");
ASSERT_EQ(parent, "/");
}
#endif
// ============================================================
// Direct unit tests for browseDirectory helper functions
// ============================================================
// Test: is_browsable_executable correctly identifies executable files
#ifdef _WIN32
TEST_F(BrowseDirectoryTest, IsBrowsableExecutable_WindowsExeExtension_ReturnsTrue) {
const std::filesystem::path exec_file = browse_test_dir / "test_exec.exe";
const std::filesystem::directory_entry entry(exec_file);
ASSERT_TRUE(confighttp::is_browsable_executable(entry, std::filesystem::status(exec_file)));
}
TEST_F(BrowseDirectoryTest, IsBrowsableExecutable_WindowsBatExtension_ReturnsTrue) {
const std::filesystem::path bat_file = browse_test_dir / "test_script.bat";
std::ofstream(bat_file) << "@echo off";
const std::filesystem::directory_entry entry(bat_file);
ASSERT_TRUE(confighttp::is_browsable_executable(entry, std::filesystem::status(bat_file)));
std::filesystem::remove(bat_file);
}
TEST_F(BrowseDirectoryTest, IsBrowsableExecutable_WindowsTxtExtension_ReturnsFalse) {
const std::filesystem::path txt_file = browse_test_dir / "file_alpha.txt";
const std::filesystem::directory_entry entry(txt_file);
ASSERT_FALSE(confighttp::is_browsable_executable(entry, std::filesystem::status(txt_file)));
}
TEST_F(BrowseDirectoryTest, IsBrowsableExecutable_WindowsCaseInsensitive_ReturnsTrue) {
// .EXE uppercase should still be recognized
const std::filesystem::path upper_exe = browse_test_dir / "UPPER.EXE";
std::ofstream(upper_exe) << "fake";
const std::filesystem::directory_entry entry(upper_exe);
ASSERT_TRUE(confighttp::is_browsable_executable(entry, std::filesystem::status(upper_exe)));
std::filesystem::remove(upper_exe);
}
#else
TEST_F(BrowseDirectoryTest, IsBrowsableExecutable_LinuxExecBitSet_ReturnsTrue) {
const std::filesystem::path exec_file = browse_test_dir / "test_exec";
const std::filesystem::directory_entry entry(exec_file);
ASSERT_TRUE(confighttp::is_browsable_executable(entry, std::filesystem::status(exec_file)));
}
TEST_F(BrowseDirectoryTest, IsBrowsableExecutable_LinuxNoExecBit_ReturnsFalse) {
const std::filesystem::path txt_file = browse_test_dir / "file_alpha.txt";
const std::filesystem::directory_entry entry(txt_file);
ASSERT_FALSE(confighttp::is_browsable_executable(entry, std::filesystem::status(txt_file)));
}
TEST_F(BrowseDirectoryTest, IsBrowsableExecutable_LinuxGroupExecBit_ReturnsTrue) {
const std::filesystem::path group_exec = browse_test_dir / "group_exec_file";
std::ofstream(group_exec) << "#!/bin/sh";
std::filesystem::permissions(
group_exec,
std::filesystem::perms::owner_read | std::filesystem::perms::owner_write | std::filesystem::perms::group_exec,
std::filesystem::perm_options::replace
);
const std::filesystem::directory_entry entry(group_exec);
ASSERT_TRUE(confighttp::is_browsable_executable(entry, std::filesystem::status(group_exec)));
std::filesystem::remove(group_exec);
}
#endif
// Test: build_browse_entries returns all entries for "any" type
TEST_F(BrowseDirectoryTest, BuildBrowseEntries_TypeAny_ReturnsAllEntries) {
const auto entries = confighttp::build_browse_entries(browse_test_dir, "any");
ASSERT_TRUE(entries.is_array());
// subdir_a, subdir_b, file_alpha.txt, file_beta.txt, test_exec[.exe] = 5
ASSERT_EQ(entries.size(), 5u);
}
// Test: build_browse_entries returns only directories for "directory" type
TEST_F(BrowseDirectoryTest, BuildBrowseEntries_TypeDirectory_OnlyReturnsDirs) {
const auto entries = confighttp::build_browse_entries(browse_test_dir, "directory");
ASSERT_TRUE(entries.is_array());
ASSERT_EQ(entries.size(), 2u); // subdir_a, subdir_b
for (const auto &e : entries) {
ASSERT_EQ(e["type"].get<std::string>(), "directory");
}
}
// Test: build_browse_entries returns dirs and files for "file" type
TEST_F(BrowseDirectoryTest, BuildBrowseEntries_TypeFile_ReturnsDirsAndFiles) {
const auto entries = confighttp::build_browse_entries(browse_test_dir, "file");
ASSERT_TRUE(entries.is_array());
const bool has_dir = std::ranges::any_of(entries, [](const nlohmann::json &e) {
return e["type"] == "directory";
});
const bool has_file = std::ranges::any_of(entries, [](const nlohmann::json &e) {
return e["type"] == "file";
});
ASSERT_TRUE(has_dir);
ASSERT_TRUE(has_file);
}
// Test: build_browse_entries for "executable" includes dirs and only executable files
TEST_F(BrowseDirectoryTest, BuildBrowseEntries_TypeExecutable_IncludesDirsAndExecFiles) {
const auto entries = confighttp::build_browse_entries(browse_test_dir, "executable");
ASSERT_TRUE(entries.is_array());
// All directories must still be present for navigation
ASSERT_NE(find_entry(entries, "subdir_a"), entries.end());
ASSERT_NE(find_entry(entries, "subdir_b"), entries.end());
#ifdef _WIN32
const std::string exec_name = "test_exec.exe";
#else
const std::string exec_name = "test_exec";
#endif
ASSERT_NE(find_entry(entries, exec_name), entries.end())
<< "Expected executable '" << exec_name << "' not found";
// Non-executable text files must NOT appear
ASSERT_EQ(find_entry(entries, "file_alpha.txt"), entries.end());
ASSERT_EQ(find_entry(entries, "file_beta.txt"), entries.end());
}
// Test: build_browse_entries sorts directories before files
TEST_F(BrowseDirectoryTest, BuildBrowseEntries_SortsDirsBeforeFiles) {
const auto entries = confighttp::build_browse_entries(browse_test_dir, "any");
ASSERT_GE(entries.size(), 3u);
bool seen_file = false;
for (const auto &e : entries) {
if (e["type"] == "file") {
seen_file = true;
} else {
// directory after a file means incorrect sort order
ASSERT_FALSE(seen_file)
<< "Directory '" << e["name"].get<std::string>() << "' appeared after a file entry";
}
}
}
// Test: build_browse_entries sorts entries alphabetically within each group
TEST_F(BrowseDirectoryTest, BuildBrowseEntries_SortsAlphabeticallyWithinGroups) {
const auto entries = confighttp::build_browse_entries(browse_test_dir, "any");
// Collect names of dirs and files separately and check they are in order
std::vector<std::string> dir_names;
std::vector<std::string> file_names;
for (const auto &e : entries) {
auto name = e["name"].get<std::string>();
std::ranges::transform(name, name.begin(), [](unsigned char c) {
return std::tolower(c);
});
if (e["type"] == "directory") {
dir_names.push_back(name);
} else {
file_names.push_back(name);
}
}
ASSERT_TRUE(std::ranges::is_sorted(dir_names))
<< "Directory names are not in alphabetical order";
ASSERT_TRUE(std::ranges::is_sorted(file_names))
<< "File names are not in alphabetical order";
}
// Test: every entry returned by build_browse_entries has the required fields
TEST_F(BrowseDirectoryTest, BuildBrowseEntries_EachEntryHasRequiredFields) {
const auto entries = confighttp::build_browse_entries(browse_test_dir, "any");
ASSERT_FALSE(entries.empty());
for (const auto &e : entries) {
ASSERT_TRUE(e.contains("name")) << "Entry missing 'name' field";
ASSERT_TRUE(e.contains("type")) << "Entry missing 'type' field";
ASSERT_TRUE(e.contains("path")) << "Entry missing 'path' field";
const std::string type = e["type"].get<std::string>();
ASSERT_TRUE(type == "directory" || type == "file")
<< "Unexpected entry type: " << type;
}
}
// Test: build_browse_entries on an empty directory returns an empty array
TEST_F(BrowseDirectoryTest, BuildBrowseEntries_EmptyDirectory_ReturnsEmptyArray) {
const std::filesystem::path empty_dir = browse_test_dir / "empty_subdir_for_test";
std::filesystem::create_directory(empty_dir);
const auto entries = confighttp::build_browse_entries(empty_dir, "any");
std::filesystem::remove(empty_dir);
ASSERT_TRUE(entries.is_array());
ASSERT_TRUE(entries.empty());
}
// Test: build_browse_entries on a non-existent path returns an empty array (does not throw)
TEST_F(BrowseDirectoryTest, BuildBrowseEntries_NonexistentDirectory_ReturnsEmptyArray) {
const auto entries = confighttp::build_browse_entries("/sunshine_nonexistent_dir_xyz_99999", "any");
ASSERT_TRUE(entries.is_array());
ASSERT_TRUE(entries.empty());
}
#ifdef _WIN32
// Test: get_windows_drives returns at least one drive
TEST_F(BrowseDirectoryTest, GetWindowsDrives_ReturnsAtLeastOneDrive) {
const auto drives = confighttp::get_windows_drives();
ASSERT_TRUE(drives.is_array());
ASSERT_GT(drives.size(), 0u);
}
// Test: get_windows_drives entries have correct name/type/path fields
TEST_F(BrowseDirectoryTest, GetWindowsDrives_EntriesHaveCorrectFormat) {
for (const auto drives = confighttp::get_windows_drives(); const auto &drive : drives) {
ASSERT_TRUE(drive.contains("name"));
ASSERT_TRUE(drive.contains("type"));
ASSERT_TRUE(drive.contains("path"));
ASSERT_EQ(drive["type"].get<std::string>(), "directory");
const std::string name = drive["name"].get<std::string>();
ASSERT_EQ(name.size(), 3u) << "Drive name should be 3 chars, e.g. 'C:\\'";
ASSERT_TRUE(std::isalpha(static_cast<unsigned char>(name[0])));
ASSERT_EQ(name[1], ':');
ASSERT_EQ(name[2], '\\');
ASSERT_EQ(drive["path"].get<std::string>(), name);
}
}
#endif
+1 -1
View File
@@ -18,7 +18,7 @@ class ProcessPNGTest: public ::testing::Test {
protected:
void SetUp() override {
// Create test directory
test_dir = fs::temp_directory_path() / "sunshine_process_png_test";
test_dir = fs::temp_directory_path() / "sunshine_process_png_test"; // NOSONAR(cpp:S5443) - safe for tests
fs::create_directories(test_dir);
}