Internal
Public Access
Compare commits
4
Commits
v2026.9.14.2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fab2f9de3 | ||
|
|
4f5cf8ecd9 | ||
|
|
df99c78998 | ||
|
|
dffca3afef |
+16
-1
@@ -1,6 +1,6 @@
|
||||
cmake_minimum_required(VERSION 3.21)
|
||||
|
||||
project(OrbitHub VERSION 2026.9.14.2 LANGUAGES CXX)
|
||||
project(OrbitHub VERSION 2026.9.15 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
@@ -16,6 +16,17 @@ find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets Sql)
|
||||
|
||||
qt_standard_project_setup()
|
||||
|
||||
option(ORBITHUB_BUILD_TESTS "Build unit tests (requires Qt6::Test)" ON)
|
||||
if(ORBITHUB_BUILD_TESTS)
|
||||
find_package(Qt6 6.2 QUIET COMPONENTS Test)
|
||||
if(TARGET Qt6::Test)
|
||||
enable_testing()
|
||||
else()
|
||||
message(STATUS "Qt6::Test not found -- skipping unit tests (set ORBITHUB_BUILD_TESTS=OFF to silence this)")
|
||||
set(ORBITHUB_BUILD_TESTS OFF)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
add_subdirectory(third_party/KodoTerm)
|
||||
|
||||
if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/FreeRDP/CMakeLists.txt")
|
||||
@@ -272,3 +283,7 @@ install(FILES third_party/KodoTerm/LICENSE
|
||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/org.darksingularity.OrbitHub
|
||||
RENAME LICENSE-KodoTerm
|
||||
)
|
||||
|
||||
if(ORBITHUB_BUILD_TESTS)
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
|
||||
@@ -16,14 +16,14 @@ OrbitHub is in active development.
|
||||
- Milestones completed: M0-M5, and M7-M9
|
||||
- Current milestone: Milestone 10 (v1.0 Stabilization)
|
||||
- Deferred milestone: Milestone 6 (VNC Fully Working)
|
||||
- Latest checkpoint tag: `v2026.9.14.2`
|
||||
- Latest checkpoint tag: `v2026.9.15`
|
||||
- VNC implementation milestone (M6) is currently deferred
|
||||
|
||||
Progress and milestone details:
|
||||
- [docs/PROGRESS.md](docs/PROGRESS.md)
|
||||
|
||||
Latest release (installers for Windows, Linux, and macOS):
|
||||
- [v2026.9.14.2](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14.2)
|
||||
- [v2026.9.15](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.15)
|
||||
|
||||
User Guide:
|
||||
- [docs/USER_GUIDE.md](docs/USER_GUIDE.md) (also available as a PDF attached to each release, and in-app via `Help -> User Guide`)
|
||||
|
||||
@@ -202,6 +202,7 @@ Git:
|
||||
- Release: [v2026.9.8.3](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.3) — same-day patch adding an in-app User Guide and standalone User Guide PDF
|
||||
- Release: [v2026.9.14](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14) — fixes distorted RDP text on HiDPI monitors and reduces RDP resize-related display glitches
|
||||
- Release: [v2026.9.14.2](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14.2) — same-day patch fixing RDP display corruption (missing/misplaced taskbar) after resizing the session window (the client never resized its own display buffer for channel-driven RDP resizes)
|
||||
- Release: [v2026.9.15](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.15) — adds Import/Export for profile lists (File menu)
|
||||
|
||||
## Milestone 10 - v1.0 Stabilization
|
||||
|
||||
|
||||
@@ -19,5 +19,5 @@ modules:
|
||||
sources:
|
||||
- type: git
|
||||
url: https://git.darksingularity.org/DarkSingularity/orbithub.git
|
||||
tag: v2026.9.14.2
|
||||
commit: 3e621219f12e9168b152140d927d672f7877b379
|
||||
tag: v2026.9.15
|
||||
commit: dffca3afef80b5a3ca4832e0b7f748775cb90328
|
||||
|
||||
@@ -17,6 +17,11 @@ mkdir -p "$DIST_DIR"
|
||||
rm -rf "$STAGE_DIR"
|
||||
mkdir -p "$PKG_ROOT/DEBIAN"
|
||||
|
||||
# Read VERSION only after the build (which reconfigures CMakeCache.txt if
|
||||
# CMakeLists.txt changed since the build dir was last configured) --
|
||||
# reading it beforehand risks packaging a stale version string.
|
||||
cmake --build "$BUILD_DIR" -j
|
||||
|
||||
VERSION="$(sed -n 's/^CMAKE_PROJECT_VERSION:STATIC=//p' "$BUILD_DIR/CMakeCache.txt" | head -n1)"
|
||||
ARCH="$(dpkg --print-architecture)"
|
||||
|
||||
@@ -25,7 +30,6 @@ if [[ -z "$VERSION" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cmake --build "$BUILD_DIR" -j
|
||||
cmake --install "$BUILD_DIR" --prefix "$PKG_ROOT/usr"
|
||||
|
||||
cat > "$PKG_ROOT/DEBIAN/control" <<EOF
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<releases>
|
||||
<release version="2026.9.15" date="2026-09-15">
|
||||
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.15</url>
|
||||
<description>
|
||||
<p>Adds Import/Export for profile lists (File menu).</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="2026.9.14.2" date="2026-09-14">
|
||||
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14.2</url>
|
||||
<description>
|
||||
|
||||
@@ -14,12 +14,6 @@ if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="$(sed -n 's/^CMAKE_PROJECT_VERSION:STATIC=//p' "$BUILD_DIR/CMakeCache.txt" | head -n1)"
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "Unable to determine project version from $BUILD_DIR/CMakeCache.txt" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MACDEPLOYQT="$(brew --prefix qt@6)/bin/macdeployqt"
|
||||
if [[ ! -x "$MACDEPLOYQT" ]]; then
|
||||
echo "macdeployqt not found at $MACDEPLOYQT" >&2
|
||||
@@ -31,7 +25,17 @@ mkdir -p "$DIST_DIR"
|
||||
rm -rf "$STAGE_DIR" "$INSTALL_PREFIX"
|
||||
mkdir -p "$STAGE_DIR"
|
||||
|
||||
# Read VERSION only after the build (which reconfigures CMakeCache.txt if
|
||||
# CMakeLists.txt changed since the build dir was last configured) --
|
||||
# reading it beforehand risks packaging a stale version string.
|
||||
cmake --build "$BUILD_DIR" -j
|
||||
|
||||
VERSION="$(sed -n 's/^CMAKE_PROJECT_VERSION:STATIC=//p' "$BUILD_DIR/CMakeCache.txt" | head -n1)"
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "Unable to determine project version from $BUILD_DIR/CMakeCache.txt" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cmake --install "$BUILD_DIR" --prefix "$INSTALL_PREFIX"
|
||||
|
||||
if [[ ! -d "$INSTALL_PREFIX/$APP_BUNDLE" ]]; then
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
#include <QVariant>
|
||||
#include <QStringList>
|
||||
|
||||
#include <atomic>
|
||||
|
||||
namespace {
|
||||
std::atomic<int> g_testConnectionCounter{0};
|
||||
|
||||
QString buildDatabasePath()
|
||||
{
|
||||
QString appDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||||
@@ -176,7 +180,7 @@ void bindProfileFields(QSqlQuery& query, const Profile& profile)
|
||||
: QStringLiteral("Negotiate"));
|
||||
query.addBindValue(isRdp ? normalizedRdpPerformanceProfile(profile.rdpPerformanceProfile)
|
||||
: QStringLiteral("Balanced"));
|
||||
query.addBindValue(normalizedTags(profile.tags));
|
||||
query.addBindValue(nonNullTrimmed(normalizedTags(profile.tags)));
|
||||
}
|
||||
|
||||
Profile profileFromQuery(const QSqlQuery& query)
|
||||
@@ -259,6 +263,16 @@ ProfileRepository::ProfileRepository() : m_connectionName(QStringLiteral("orbith
|
||||
}
|
||||
}
|
||||
|
||||
ProfileRepository::ProfileRepository(const QString& databasePathOverride)
|
||||
: m_connectionName(QStringLiteral("orbithub_test_%1")
|
||||
.arg(g_testConnectionCounter.fetch_add(1))),
|
||||
m_databasePathOverride(databasePathOverride)
|
||||
{
|
||||
if (!initializeDatabase()) {
|
||||
QSqlDatabase::removeDatabase(m_connectionName);
|
||||
}
|
||||
}
|
||||
|
||||
ProfileRepository::~ProfileRepository()
|
||||
{
|
||||
if (QSqlDatabase::contains(m_connectionName)) {
|
||||
@@ -485,7 +499,8 @@ bool ProfileRepository::deleteProfile(qint64 id) const
|
||||
bool ProfileRepository::initializeDatabase()
|
||||
{
|
||||
QSqlDatabase database = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
|
||||
database.setDatabaseName(buildDatabasePath());
|
||||
database.setDatabaseName(
|
||||
m_databasePathOverride.isEmpty() ? buildDatabasePath() : m_databasePathOverride);
|
||||
|
||||
if (!database.open()) {
|
||||
m_initError = database.lastError().text();
|
||||
|
||||
@@ -35,6 +35,9 @@ class ProfileRepository
|
||||
{
|
||||
public:
|
||||
ProfileRepository();
|
||||
// databasePathOverride lets tests point the repository at an isolated,
|
||||
// disposable SQLite file instead of the real app-data location.
|
||||
explicit ProfileRepository(const QString& databasePathOverride);
|
||||
~ProfileRepository();
|
||||
|
||||
QString initError() const;
|
||||
@@ -51,6 +54,7 @@ public:
|
||||
|
||||
private:
|
||||
QString m_connectionName;
|
||||
QString m_databasePathOverride;
|
||||
QString m_initError;
|
||||
mutable QString m_lastError;
|
||||
|
||||
|
||||
@@ -7,8 +7,13 @@
|
||||
#include <QAction>
|
||||
#include <QAbstractItemView>
|
||||
#include <QComboBox>
|
||||
#include <QFile>
|
||||
#include <QFileDialog>
|
||||
#include <QHeaderView>
|
||||
#include <QHBoxLayout>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QInputDialog>
|
||||
@@ -85,6 +90,53 @@ bool profileHasTag(const Profile& profile, const QString& requestedTag)
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
constexpr int kProfileExportFormatVersion = 1;
|
||||
|
||||
QJsonObject profileToJson(const Profile& profile)
|
||||
{
|
||||
QJsonObject object;
|
||||
object.insert(QStringLiteral("name"), profile.name);
|
||||
object.insert(QStringLiteral("host"), profile.host);
|
||||
object.insert(QStringLiteral("port"), profile.port);
|
||||
object.insert(QStringLiteral("username"), profile.username);
|
||||
object.insert(QStringLiteral("domain"), profile.domain);
|
||||
object.insert(QStringLiteral("folderPath"), profile.folderPath);
|
||||
object.insert(QStringLiteral("protocol"), profile.protocol);
|
||||
object.insert(QStringLiteral("authMode"), profile.authMode);
|
||||
object.insert(QStringLiteral("privateKeyPath"), profile.privateKeyPath);
|
||||
object.insert(QStringLiteral("knownHostsPolicy"), profile.knownHostsPolicy);
|
||||
object.insert(QStringLiteral("rdpSecurityMode"), profile.rdpSecurityMode);
|
||||
object.insert(QStringLiteral("rdpPerformanceProfile"), profile.rdpPerformanceProfile);
|
||||
object.insert(QStringLiteral("tags"), profile.tags);
|
||||
return object;
|
||||
}
|
||||
|
||||
// Deliberately excludes id (import always creates new rows -- an imported
|
||||
// profile's id has no meaning in the destination database) and any
|
||||
// credential material (none is ever persisted on Profile in the first
|
||||
// place, see ProfileRepository).
|
||||
Profile profileFromJson(const QJsonObject& object)
|
||||
{
|
||||
Profile profile;
|
||||
profile.name = object.value(QStringLiteral("name")).toString();
|
||||
profile.host = object.value(QStringLiteral("host")).toString();
|
||||
profile.port = object.value(QStringLiteral("port")).toInt(22);
|
||||
profile.username = object.value(QStringLiteral("username")).toString();
|
||||
profile.domain = object.value(QStringLiteral("domain")).toString();
|
||||
profile.folderPath = object.value(QStringLiteral("folderPath")).toString();
|
||||
profile.protocol = object.value(QStringLiteral("protocol")).toString(QStringLiteral("SSH"));
|
||||
profile.authMode = object.value(QStringLiteral("authMode")).toString(QStringLiteral("Password"));
|
||||
profile.privateKeyPath = object.value(QStringLiteral("privateKeyPath")).toString();
|
||||
profile.knownHostsPolicy =
|
||||
object.value(QStringLiteral("knownHostsPolicy")).toString(QStringLiteral("Ask"));
|
||||
profile.rdpSecurityMode =
|
||||
object.value(QStringLiteral("rdpSecurityMode")).toString(QStringLiteral("Negotiate"));
|
||||
profile.rdpPerformanceProfile =
|
||||
object.value(QStringLiteral("rdpPerformanceProfile")).toString(QStringLiteral("Balanced"));
|
||||
profile.tags = object.value(QStringLiteral("tags")).toString();
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
|
||||
ProfilesWindow::ProfilesWindow(QWidget* parent)
|
||||
@@ -930,3 +982,128 @@ void ProfilesWindow::createFolderInCurrentContext()
|
||||
const QString folderPath = folderPathForItem(m_profilesTree->currentItem());
|
||||
createFolderInContext(folderPath);
|
||||
}
|
||||
|
||||
void ProfilesWindow::exportProfiles()
|
||||
{
|
||||
const QString fileName = QFileDialog::getSaveFileName(
|
||||
this, QStringLiteral("Export Profiles"), QStringLiteral("orbithub-profiles.json"),
|
||||
QStringLiteral("JSON Files (*.json)"));
|
||||
if (fileName.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonArray folders;
|
||||
for (const QString& folderPath : m_repository->listFolders()) {
|
||||
folders.append(folderPath);
|
||||
}
|
||||
|
||||
QJsonArray profiles;
|
||||
for (const Profile& profile : m_repository->listProfiles()) {
|
||||
profiles.append(profileToJson(profile));
|
||||
}
|
||||
|
||||
QJsonObject root;
|
||||
root.insert(QStringLiteral("orbithubProfileExport"), kProfileExportFormatVersion);
|
||||
root.insert(QStringLiteral("folders"), folders);
|
||||
root.insert(QStringLiteral("profiles"), profiles);
|
||||
|
||||
QFile file(fileName);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Export Profiles"),
|
||||
QStringLiteral("Failed to write %1: %2")
|
||||
.arg(fileName, file.errorString()));
|
||||
return;
|
||||
}
|
||||
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
|
||||
file.close();
|
||||
|
||||
QMessageBox::information(this,
|
||||
QStringLiteral("Export Profiles"),
|
||||
QStringLiteral("Exported %1 profile(s) to %2.")
|
||||
.arg(profiles.size())
|
||||
.arg(fileName));
|
||||
}
|
||||
|
||||
void ProfilesWindow::importProfiles()
|
||||
{
|
||||
const QString fileName = QFileDialog::getOpenFileName(
|
||||
this, QStringLiteral("Import Profiles"), QString(), QStringLiteral("JSON Files (*.json)"));
|
||||
if (fileName.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QFile file(fileName);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Import Profiles"),
|
||||
QStringLiteral("Failed to read %1: %2")
|
||||
.arg(fileName, file.errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonParseError parseError{};
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseError);
|
||||
file.close();
|
||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Import Profiles"),
|
||||
QStringLiteral("%1 is not a valid OrbitHub profile export: %2")
|
||||
.arg(fileName, parseError.errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonObject root = doc.object();
|
||||
if (!root.value(QStringLiteral("profiles")).isArray()) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Import Profiles"),
|
||||
QStringLiteral("%1 does not contain a profile list.").arg(fileName));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const QJsonValue& folderValue : root.value(QStringLiteral("folders")).toArray()) {
|
||||
const QString folderPath = folderValue.toString();
|
||||
if (!folderPath.isEmpty()) {
|
||||
m_repository->createFolder(folderPath);
|
||||
}
|
||||
}
|
||||
|
||||
const QJsonArray profilesArray = root.value(QStringLiteral("profiles")).toArray();
|
||||
int imported = 0;
|
||||
QStringList failures;
|
||||
for (const QJsonValue& profileValue : profilesArray) {
|
||||
if (!profileValue.isObject()) {
|
||||
continue;
|
||||
}
|
||||
const Profile profile = profileFromJson(profileValue.toObject());
|
||||
if (profile.name.isEmpty() || profile.host.isEmpty()) {
|
||||
failures.push_back(QStringLiteral("(unnamed profile): missing name or host"));
|
||||
continue;
|
||||
}
|
||||
// No need to createFolder(profile.folderPath) here: the tree view
|
||||
// already synthesizes folder nodes from a profile's own folderPath
|
||||
// (see ProfilesWindow::loadProfiles). Explicit profile_folders rows
|
||||
// are only for folders with no profiles in them, and those are
|
||||
// already recreated above from the export's top-level "folders"
|
||||
// list -- doing it again per-profile would just add spurious
|
||||
// entries not present in the original export.
|
||||
if (m_repository->createProfile(profile).has_value()) {
|
||||
++imported;
|
||||
} else {
|
||||
failures.push_back(QStringLiteral("%1: %2").arg(profile.name, m_repository->lastError()));
|
||||
}
|
||||
}
|
||||
|
||||
loadProfiles();
|
||||
|
||||
QString summary = QStringLiteral("Imported %1 of %2 profile(s) from %3.")
|
||||
.arg(imported)
|
||||
.arg(profilesArray.size())
|
||||
.arg(fileName);
|
||||
if (!failures.isEmpty()) {
|
||||
summary += QStringLiteral("\n\nFailed:\n%1").arg(failures.join(QChar::fromLatin1('\n')));
|
||||
QMessageBox::warning(this, QStringLiteral("Import Profiles"), summary);
|
||||
} else {
|
||||
QMessageBox::information(this, QStringLiteral("Import Profiles"), summary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ public:
|
||||
|
||||
void createProfileInCurrentContext();
|
||||
void createFolderInCurrentContext();
|
||||
void exportProfiles();
|
||||
void importProfiles();
|
||||
|
||||
signals:
|
||||
void connectRequested(const Profile& profile);
|
||||
|
||||
@@ -170,6 +170,9 @@ SessionWindow::SessionWindow(QWidget* parent)
|
||||
QAction* newProfileAction = fileMenu->addAction(QStringLiteral("New Profile"));
|
||||
QAction* newFolderAction = fileMenu->addAction(QStringLiteral("New Folder"));
|
||||
fileMenu->addSeparator();
|
||||
QAction* importProfilesAction = fileMenu->addAction(QStringLiteral("Import Profiles..."));
|
||||
QAction* exportProfilesAction = fileMenu->addAction(QStringLiteral("Export Profiles..."));
|
||||
fileMenu->addSeparator();
|
||||
QAction* quitAction = fileMenu->addAction(QStringLiteral("Quit"));
|
||||
|
||||
connect(newProfileAction,
|
||||
@@ -180,6 +183,14 @@ SessionWindow::SessionWindow(QWidget* parent)
|
||||
&QAction::triggered,
|
||||
this,
|
||||
[this]() { m_profilesWidget->createFolderInCurrentContext(); });
|
||||
connect(importProfilesAction,
|
||||
&QAction::triggered,
|
||||
this,
|
||||
[this]() { m_profilesWidget->importProfiles(); });
|
||||
connect(exportProfilesAction,
|
||||
&QAction::triggered,
|
||||
this,
|
||||
[this]() { m_profilesWidget->exportProfiles(); });
|
||||
connect(quitAction, &QAction::triggered, this, []() { qApp->quit(); });
|
||||
|
||||
QMenu* helpMenu = menuBar()->addMenu(QStringLiteral("Help"));
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
add_executable(test_profile_repository
|
||||
test_profile_repository.cpp
|
||||
${CMAKE_SOURCE_DIR}/src/profile_repository.cpp
|
||||
)
|
||||
target_include_directories(test_profile_repository PRIVATE ${CMAKE_SOURCE_DIR}/src)
|
||||
target_link_libraries(test_profile_repository PRIVATE Qt6::Core Qt6::Sql Qt6::Test)
|
||||
add_test(NAME test_profile_repository COMMAND test_profile_repository)
|
||||
@@ -0,0 +1,288 @@
|
||||
#include "profile_repository.h"
|
||||
|
||||
#include <QTemporaryDir>
|
||||
#include <QTest>
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace {
|
||||
Profile makeSshProfile(const QString& name = QStringLiteral("Prod SSH Box"))
|
||||
{
|
||||
Profile profile;
|
||||
profile.name = name;
|
||||
profile.host = QStringLiteral("prod.example.com");
|
||||
profile.port = 22;
|
||||
profile.username = QStringLiteral("deploy");
|
||||
profile.protocol = QStringLiteral("SSH");
|
||||
profile.authMode = QStringLiteral("Password");
|
||||
profile.tags = QStringLiteral("prod,linux");
|
||||
return profile;
|
||||
}
|
||||
|
||||
Profile makeRdpProfile(const QString& name = QStringLiteral("Windows RDP Box"))
|
||||
{
|
||||
Profile profile;
|
||||
profile.name = name;
|
||||
profile.host = QStringLiteral("win.example.com");
|
||||
profile.port = 3389;
|
||||
profile.username = QStringLiteral("admin");
|
||||
profile.domain = QStringLiteral("CORP");
|
||||
profile.protocol = QStringLiteral("RDP");
|
||||
profile.rdpSecurityMode = QStringLiteral("NLA");
|
||||
profile.rdpPerformanceProfile = QStringLiteral("Best Performance");
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
|
||||
class TestProfileRepository : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void init();
|
||||
void cleanup();
|
||||
|
||||
void initializesCleanly();
|
||||
void createAndGetSshProfile();
|
||||
void createAndGetRdpProfile();
|
||||
void createProfileRejectsMissingName();
|
||||
void createProfileRejectsMissingHost();
|
||||
void createProfileRejectsInvalidPort();
|
||||
void createProfileRejectsMissingUsernameForSsh();
|
||||
void createProfileRejectsMissingPrivateKeyForKeyAuth();
|
||||
void createProfileRejectsDuplicateName();
|
||||
void updateProfilePersistsChanges();
|
||||
void deleteProfileRemovesIt();
|
||||
void getProfileReturnsNulloptForUnknownId();
|
||||
void listProfilesFiltersBySearchQuery();
|
||||
void listProfilesSortsByRequestedOrder();
|
||||
void tagsAreTrimmedDedupedAndJoined();
|
||||
void emptyTagsRoundTripAsEmpty();
|
||||
void folderCreateAndListRoundTrips();
|
||||
void folderCreateIgnoresDuplicates();
|
||||
void folderPathIsNormalized();
|
||||
|
||||
private:
|
||||
std::unique_ptr<QTemporaryDir> m_tempDir;
|
||||
std::unique_ptr<ProfileRepository> m_repo;
|
||||
};
|
||||
|
||||
void TestProfileRepository::init()
|
||||
{
|
||||
m_tempDir = std::make_unique<QTemporaryDir>();
|
||||
QVERIFY(m_tempDir->isValid());
|
||||
m_repo = std::make_unique<ProfileRepository>(m_tempDir->filePath(QStringLiteral("test.sqlite")));
|
||||
}
|
||||
|
||||
void TestProfileRepository::cleanup()
|
||||
{
|
||||
m_repo.reset();
|
||||
m_tempDir.reset();
|
||||
}
|
||||
|
||||
void TestProfileRepository::initializesCleanly()
|
||||
{
|
||||
QCOMPARE(m_repo->initError(), QString());
|
||||
QCOMPARE(m_repo->listProfiles().size(), size_t(0));
|
||||
QCOMPARE(m_repo->listFolders().size(), size_t(0));
|
||||
}
|
||||
|
||||
void TestProfileRepository::createAndGetSshProfile()
|
||||
{
|
||||
const Profile input = makeSshProfile();
|
||||
const std::optional<Profile> created = m_repo->createProfile(input);
|
||||
QVERIFY(created.has_value());
|
||||
QVERIFY(created->id > 0);
|
||||
|
||||
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
|
||||
QVERIFY(fetched.has_value());
|
||||
QCOMPARE(fetched->name, input.name);
|
||||
QCOMPARE(fetched->host, input.host);
|
||||
QCOMPARE(fetched->port, input.port);
|
||||
QCOMPARE(fetched->username, input.username);
|
||||
QCOMPARE(fetched->protocol, QStringLiteral("SSH"));
|
||||
QCOMPARE(fetched->authMode, QStringLiteral("Password"));
|
||||
QCOMPARE(fetched->tags, QStringLiteral("prod, linux"));
|
||||
}
|
||||
|
||||
void TestProfileRepository::createAndGetRdpProfile()
|
||||
{
|
||||
const Profile input = makeRdpProfile();
|
||||
const std::optional<Profile> created = m_repo->createProfile(input);
|
||||
QVERIFY(created.has_value());
|
||||
|
||||
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
|
||||
QVERIFY(fetched.has_value());
|
||||
QCOMPARE(fetched->protocol, QStringLiteral("RDP"));
|
||||
QCOMPARE(fetched->domain, QStringLiteral("CORP"));
|
||||
QCOMPARE(fetched->rdpSecurityMode, QStringLiteral("NLA"));
|
||||
QCOMPARE(fetched->rdpPerformanceProfile, QStringLiteral("Best Performance"));
|
||||
QCOMPARE(fetched->port, 3389);
|
||||
// Auth-mode/private-key fields are SSH-only and must not leak through
|
||||
// for a non-SSH protocol.
|
||||
QCOMPARE(fetched->authMode, QStringLiteral("Password"));
|
||||
QCOMPARE(fetched->privateKeyPath, QString());
|
||||
}
|
||||
|
||||
void TestProfileRepository::createProfileRejectsMissingName()
|
||||
{
|
||||
Profile profile = makeSshProfile();
|
||||
profile.name.clear();
|
||||
QVERIFY(!m_repo->createProfile(profile).has_value());
|
||||
QVERIFY(!m_repo->lastError().isEmpty());
|
||||
}
|
||||
|
||||
void TestProfileRepository::createProfileRejectsMissingHost()
|
||||
{
|
||||
Profile profile = makeSshProfile();
|
||||
profile.host.clear();
|
||||
QVERIFY(!m_repo->createProfile(profile).has_value());
|
||||
QVERIFY(!m_repo->lastError().isEmpty());
|
||||
}
|
||||
|
||||
void TestProfileRepository::createProfileRejectsInvalidPort()
|
||||
{
|
||||
Profile profile = makeSshProfile();
|
||||
profile.port = 0;
|
||||
QVERIFY(!m_repo->createProfile(profile).has_value());
|
||||
|
||||
profile.port = 70000;
|
||||
QVERIFY(!m_repo->createProfile(profile).has_value());
|
||||
}
|
||||
|
||||
void TestProfileRepository::createProfileRejectsMissingUsernameForSsh()
|
||||
{
|
||||
Profile profile = makeSshProfile();
|
||||
profile.username.clear();
|
||||
QVERIFY(!m_repo->createProfile(profile).has_value());
|
||||
}
|
||||
|
||||
void TestProfileRepository::createProfileRejectsMissingPrivateKeyForKeyAuth()
|
||||
{
|
||||
Profile profile = makeSshProfile();
|
||||
profile.authMode = QStringLiteral("Private Key");
|
||||
profile.privateKeyPath.clear();
|
||||
QVERIFY(!m_repo->createProfile(profile).has_value());
|
||||
}
|
||||
|
||||
void TestProfileRepository::createProfileRejectsDuplicateName()
|
||||
{
|
||||
QVERIFY(m_repo->createProfile(makeSshProfile(QStringLiteral("Same Name"))).has_value());
|
||||
QVERIFY(!m_repo->createProfile(makeSshProfile(QStringLiteral("Same Name"))).has_value());
|
||||
}
|
||||
|
||||
void TestProfileRepository::updateProfilePersistsChanges()
|
||||
{
|
||||
const std::optional<Profile> created = m_repo->createProfile(makeSshProfile());
|
||||
QVERIFY(created.has_value());
|
||||
|
||||
Profile updated = created.value();
|
||||
updated.host = QStringLiteral("new-host.example.com");
|
||||
updated.port = 2222;
|
||||
updated.tags = QStringLiteral("updated");
|
||||
QVERIFY(m_repo->updateProfile(updated));
|
||||
|
||||
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
|
||||
QVERIFY(fetched.has_value());
|
||||
QCOMPARE(fetched->host, QStringLiteral("new-host.example.com"));
|
||||
QCOMPARE(fetched->port, 2222);
|
||||
QCOMPARE(fetched->tags, QStringLiteral("updated"));
|
||||
}
|
||||
|
||||
void TestProfileRepository::deleteProfileRemovesIt()
|
||||
{
|
||||
const std::optional<Profile> created = m_repo->createProfile(makeSshProfile());
|
||||
QVERIFY(created.has_value());
|
||||
QVERIFY(m_repo->deleteProfile(created->id));
|
||||
QVERIFY(!m_repo->getProfile(created->id).has_value());
|
||||
}
|
||||
|
||||
void TestProfileRepository::getProfileReturnsNulloptForUnknownId()
|
||||
{
|
||||
QVERIFY(!m_repo->getProfile(999999).has_value());
|
||||
}
|
||||
|
||||
void TestProfileRepository::listProfilesFiltersBySearchQuery()
|
||||
{
|
||||
QVERIFY(m_repo->createProfile(makeSshProfile(QStringLiteral("Alpha"))).has_value());
|
||||
QVERIFY(m_repo->createProfile(makeRdpProfile(QStringLiteral("Beta"))).has_value());
|
||||
|
||||
const auto byName = m_repo->listProfiles(QStringLiteral("Alpha"));
|
||||
QCOMPARE(byName.size(), size_t(1));
|
||||
QCOMPARE(byName[0].name, QStringLiteral("Alpha"));
|
||||
|
||||
const auto byHost = m_repo->listProfiles(QStringLiteral("win.example"));
|
||||
QCOMPARE(byHost.size(), size_t(1));
|
||||
QCOMPARE(byHost[0].name, QStringLiteral("Beta"));
|
||||
|
||||
const auto byTag = m_repo->listProfiles(QStringLiteral("linux"));
|
||||
QCOMPARE(byTag.size(), size_t(1));
|
||||
QCOMPARE(byTag[0].name, QStringLiteral("Alpha"));
|
||||
|
||||
QCOMPARE(m_repo->listProfiles(QStringLiteral("nonexistent")).size(), size_t(0));
|
||||
}
|
||||
|
||||
void TestProfileRepository::listProfilesSortsByRequestedOrder()
|
||||
{
|
||||
QVERIFY(m_repo->createProfile(makeSshProfile(QStringLiteral("Zeta"))).has_value());
|
||||
QVERIFY(m_repo->createProfile(makeRdpProfile(QStringLiteral("Alpha"))).has_value());
|
||||
|
||||
const auto byName = m_repo->listProfiles(QString(), ProfileSortOrder::NameAsc);
|
||||
QCOMPARE(byName.size(), size_t(2));
|
||||
QCOMPARE(byName[0].name, QStringLiteral("Alpha"));
|
||||
QCOMPARE(byName[1].name, QStringLiteral("Zeta"));
|
||||
|
||||
const auto byProtocol = m_repo->listProfiles(QString(), ProfileSortOrder::ProtocolAsc);
|
||||
QCOMPARE(byProtocol[0].protocol, QStringLiteral("RDP"));
|
||||
QCOMPARE(byProtocol[1].protocol, QStringLiteral("SSH"));
|
||||
}
|
||||
|
||||
void TestProfileRepository::tagsAreTrimmedDedupedAndJoined()
|
||||
{
|
||||
Profile profile = makeSshProfile();
|
||||
profile.tags = QStringLiteral(" prod ,, Prod , linux ,linux");
|
||||
const std::optional<Profile> created = m_repo->createProfile(profile);
|
||||
QVERIFY(created.has_value());
|
||||
|
||||
// createProfile()'s return value echoes the input as-is; normalization
|
||||
// only happens on the DB round trip, so re-fetch to observe it.
|
||||
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
|
||||
QVERIFY(fetched.has_value());
|
||||
// Case-insensitive de-dup keeps the first-seen casing of each tag.
|
||||
QCOMPARE(fetched->tags, QStringLiteral("prod, linux"));
|
||||
}
|
||||
|
||||
void TestProfileRepository::emptyTagsRoundTripAsEmpty()
|
||||
{
|
||||
const std::optional<Profile> created = m_repo->createProfile(makeRdpProfile());
|
||||
QVERIFY(created.has_value());
|
||||
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
|
||||
QVERIFY(fetched.has_value());
|
||||
QCOMPARE(fetched->tags, QString());
|
||||
}
|
||||
|
||||
void TestProfileRepository::folderCreateAndListRoundTrips()
|
||||
{
|
||||
QVERIFY(m_repo->createFolder(QStringLiteral("Work/Servers")));
|
||||
const auto folders = m_repo->listFolders();
|
||||
QCOMPARE(folders.size(), size_t(1));
|
||||
QCOMPARE(folders[0], QStringLiteral("Work/Servers"));
|
||||
}
|
||||
|
||||
void TestProfileRepository::folderCreateIgnoresDuplicates()
|
||||
{
|
||||
QVERIFY(m_repo->createFolder(QStringLiteral("Work")));
|
||||
QVERIFY(m_repo->createFolder(QStringLiteral("Work")));
|
||||
QCOMPARE(m_repo->listFolders().size(), size_t(1));
|
||||
}
|
||||
|
||||
void TestProfileRepository::folderPathIsNormalized()
|
||||
{
|
||||
QVERIFY(m_repo->createFolder(QStringLiteral("\\Work\\\\Servers\\")));
|
||||
const auto folders = m_repo->listFolders();
|
||||
QCOMPARE(folders.size(), size_t(1));
|
||||
QCOMPARE(folders[0], QStringLiteral("Work/Servers"));
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(TestProfileRepository)
|
||||
#include "test_profile_repository.moc"
|
||||
Reference in New Issue
Block a user