5 Commits
Author SHA1 Message Date
ksmithandClaude Sonnet 5 04ce6f7904 Add RdpSessionBackend test coverage (#1)
RdpSessionBackend can't reasonably get the same fixture-driven
state-machine tests SshSessionBackend got: it's driven by FreeRDP's own
event loop and a raw worker thread against a real freerdp_connect(), not
a QProcess we can point at a stand-in binary. What it does have is a
large amount of pure, regression-prone logic -- exactly the kind that
already caused a real historical bug here (the X11-keycode/PC-AT-scancode
mixup fixed in Milestone 7) -- so that's what gets covered instead.

Twelve functions promoted from free functions / private members to
public statics purely so tests can call them without a live connection:
security-mode/performance-profile normalization, the HiDPI scale-value
mapping, desktop-size clamping, both scancode-mapping functions, and the
five FreeRDP error-code interpretation functions. UINT32 is surfaced as
quint32 in the public signatures to keep FreeRDP/WinPR types out of the
header, matching how rdp_freerdp* is already only forward-declared there.

27 test cases, including a couple of direct regression guards: verifying
scancodeFromNativeScanCode() is a faithful passthrough to FreeRDP's X11
table (not a reimplementation), and that it does NOT reproduce the old
"X11 keycode treated as PC/AT scancode" bug for a documented example key.

This closes out #1's originally scoped work (CTest wiring, ProfileRepository,
SshSessionBackend, RdpSessionBackend coverage). Deeper state-machine
coverage for the two session backends remains future work if ever needed,
but isn't blocking here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 15:32:02 -06:00
ksmithandClaude Sonnet 5 9842a44de0 Add SshSessionBackend test coverage (#1)
Adds two kinds of coverage, continuing #1's remaining scope:

1. Pure-function tests for mapSshError() and escapeForShellSingleQuotes(),
   promoted from private members to public statics purely so tests can
   call them without spinning up a process. escapeForShellSingleQuotes()
   is the actual security boundary for password auth (it's what stops a
   password containing a single quote from breaking out of the askpass
   script's quoting), so it gets a real adversarial test, not just a
   happy-path one.

2. State-machine tests (connect -> Connected, auth failure -> Failed with
   the right mapped message, connection refused -> Failed, input
   round-tripping, reconnect) driven against tests/fixtures/fake_ssh.sh,
   a small controllable stand-in for the real ssh binary, instead of a
   real network/SSH server. This needed one small testability seam: a new
   constructor overload that overrides the launched program ("ssh" in
   production, the fixture script in tests).

POSIX-only for now: the fixture is a shell script, so the state-machine
tests QSKIP on Windows until an equivalent fixture exists there; the
pure-function tests run everywhere.

RdpSessionBackend coverage is still open -- it's a bigger lift again
(FreeRDP's own event loop, not just a QProcess), left for a follow-up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 15:16:32 -06:00
ksmithandClaude Sonnet 5 eb63bde870 Add Delete Folder to the profile tree context menu
ProfileRepository::deleteFolder() removes a folder without ever deleting
the profiles or subfolders inside it -- everything directly under the
deleted folder shifts up to take its place (its parent, or the top level
if it had none), exactly as if that one path segment were removed from
each affected path. This is a labels-only operation (a "folder" is just a
grouping string on each profile, not a container that owns them), so
that's the least-surprising behavior versus silently bulk-deleting saved
connections.

Right-clicking a folder in the tree now offers "Delete Folder"; if it
isn't empty, a confirmation states exactly how many profiles/subfolders
will move and to where.

Covered by 7 new unit tests, which caught the same class of bug fixed in
3fab2f9: the new code's own folder-path remap also bound an unguarded
null QString (a folder moving to root) against the `folder_path NOT
NULL` column.

Fixes #20.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 15:08:37 -06:00
ksmithandClaude Sonnet 5 3fab2f9de3 Wire up CTest and add unit test coverage for ProfileRepository
Adds a Qt6::Test-based unit test target (tests/test_profile_repository.cpp,
21 cases), gated behind an ORBITHUB_BUILD_TESTS option that no-ops
gracefully if Qt6::Test isn't available, so it can't break app-only
builds. Covers profile CRUD, validation rules, search/sort, tag
normalization, and folder handling, each against an isolated temporary
SQLite file (new ProfileRepository(databasePathOverride) constructor
overload added for exactly this).

Caught and fixed a real bug along the way: normalizedTags()'s result was
bound directly without the nonNullTrimmed() null-guard every other field
already uses, so creating a profile with no tags at all hit the `tags
NOT NULL` constraint and silently failed -- including via the Import
Profiles feature for any export where a profile has no tags key.

Partial progress on #1 (RdpSessionBackend/SshSessionBackend state-machine
coverage still open -- much larger lift, needs a testability pass on
those backends first).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 06:39:20 -06:00
ksmithandClaude Sonnet 5 4f5cf8ecd9 packaging: read project version after building, not before
build-deb.sh and build-dmg.sh both read VERSION from CMakeCache.txt
before calling cmake --build -- but that build step is exactly what
reconfigures CMakeCache.txt if CMakeLists.txt changed since the build
dir was last configured. If the version was bumped and the script is
run without an explicit reconfigure first, it silently packages the
stale version (observed: v2026.9.15's macOS build produced
OrbitHub-2026.9.14.2.dmg). Move the VERSION read to after the build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 06:26:15 -06:00
16 changed files with 1313 additions and 81 deletions
+15
View File
@@ -16,6 +16,17 @@ find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets Sql)
qt_standard_project_setup() 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) add_subdirectory(third_party/KodoTerm)
if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/FreeRDP/CMakeLists.txt") 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 DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/org.darksingularity.OrbitHub
RENAME LICENSE-KodoTerm RENAME LICENSE-KodoTerm
) )
if(ORBITHUB_BUILD_TESTS)
add_subdirectory(tests)
endif()
+5 -1
View File
@@ -17,6 +17,11 @@ mkdir -p "$DIST_DIR"
rm -rf "$STAGE_DIR" rm -rf "$STAGE_DIR"
mkdir -p "$PKG_ROOT/DEBIAN" 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)" VERSION="$(sed -n 's/^CMAKE_PROJECT_VERSION:STATIC=//p' "$BUILD_DIR/CMakeCache.txt" | head -n1)"
ARCH="$(dpkg --print-architecture)" ARCH="$(dpkg --print-architecture)"
@@ -25,7 +30,6 @@ if [[ -z "$VERSION" ]]; then
exit 1 exit 1
fi fi
cmake --build "$BUILD_DIR" -j
cmake --install "$BUILD_DIR" --prefix "$PKG_ROOT/usr" cmake --install "$BUILD_DIR" --prefix "$PKG_ROOT/usr"
cat > "$PKG_ROOT/DEBIAN/control" <<EOF cat > "$PKG_ROOT/DEBIAN/control" <<EOF
+10 -6
View File
@@ -14,12 +14,6 @@ if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then
exit 1 exit 1
fi 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" MACDEPLOYQT="$(brew --prefix qt@6)/bin/macdeployqt"
if [[ ! -x "$MACDEPLOYQT" ]]; then if [[ ! -x "$MACDEPLOYQT" ]]; then
echo "macdeployqt not found at $MACDEPLOYQT" >&2 echo "macdeployqt not found at $MACDEPLOYQT" >&2
@@ -31,7 +25,17 @@ mkdir -p "$DIST_DIR"
rm -rf "$STAGE_DIR" "$INSTALL_PREFIX" rm -rf "$STAGE_DIR" "$INSTALL_PREFIX"
mkdir -p "$STAGE_DIR" 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 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" cmake --install "$BUILD_DIR" --prefix "$INSTALL_PREFIX"
if [[ ! -d "$INSTALL_PREFIX/$APP_BUNDLE" ]]; then if [[ ! -d "$INSTALL_PREFIX/$APP_BUNDLE" ]]; then
+134 -2
View File
@@ -9,7 +9,11 @@
#include <QVariant> #include <QVariant>
#include <QStringList> #include <QStringList>
#include <atomic>
namespace { namespace {
std::atomic<int> g_testConnectionCounter{0};
QString buildDatabasePath() QString buildDatabasePath()
{ {
QString appDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); QString appDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
@@ -176,7 +180,7 @@ void bindProfileFields(QSqlQuery& query, const Profile& profile)
: QStringLiteral("Negotiate")); : QStringLiteral("Negotiate"));
query.addBindValue(isRdp ? normalizedRdpPerformanceProfile(profile.rdpPerformanceProfile) query.addBindValue(isRdp ? normalizedRdpPerformanceProfile(profile.rdpPerformanceProfile)
: QStringLiteral("Balanced")); : QStringLiteral("Balanced"));
query.addBindValue(normalizedTags(profile.tags)); query.addBindValue(nonNullTrimmed(normalizedTags(profile.tags)));
} }
Profile profileFromQuery(const QSqlQuery& query) 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() ProfileRepository::~ProfileRepository()
{ {
if (QSqlDatabase::contains(m_connectionName)) { if (QSqlDatabase::contains(m_connectionName)) {
@@ -332,6 +346,123 @@ bool ProfileRepository::createFolder(const QString& folderPath) const
return true; return true;
} }
// Deleting a folder never destroys profiles: everything directly inside it
// (profiles and subfolders alike) is shifted up to take the deleted
// folder's place, exactly as if that one path segment had been removed
// from each of their paths. This is the least-surprising behavior for what
// is fundamentally just an organizational label, not a container that owns
// its contents.
bool ProfileRepository::deleteFolder(const QString& folderPath) const
{
if (!QSqlDatabase::contains(m_connectionName)) {
return false;
}
const QString normalized = normalizedFolderPath(folderPath);
if (normalized.isEmpty()) {
setLastError(QStringLiteral("Folder path is required."));
return false;
}
setLastError(QString());
QString parentPath;
const int lastSlash = normalized.lastIndexOf(QChar::fromLatin1('/'));
if (lastSlash >= 0) {
parentPath = normalized.left(lastSlash);
}
const QString prefix = normalized + QStringLiteral("/");
auto remap = [&](const QString& oldPath) {
if (oldPath == normalized) {
return nonNullTrimmed(parentPath);
}
const QString remainder = oldPath.mid(prefix.length());
return nonNullTrimmed(parentPath.isEmpty() ? remainder
: parentPath + QStringLiteral("/") + remainder);
};
QSqlDatabase database = QSqlDatabase::database(m_connectionName);
if (!database.transaction()) {
setLastError(database.lastError().text());
return false;
}
// Reassign affected profiles. Matching is done in C++ (not SQL LIKE)
// so a folder name containing '%' or '_' can't be misinterpreted as a
// wildcard.
QSqlQuery selectProfiles(database);
if (!selectProfiles.exec(QStringLiteral("SELECT id, folder_path FROM profiles"))) {
setLastError(selectProfiles.lastError().text());
database.rollback();
return false;
}
std::vector<std::pair<qint64, QString>> profileUpdates;
while (selectProfiles.next()) {
const QString path = normalizedFolderPath(selectProfiles.value(1).toString());
if (path == normalized || path.startsWith(prefix)) {
profileUpdates.emplace_back(selectProfiles.value(0).toLongLong(), remap(path));
}
}
for (const auto& [id, newPath] : profileUpdates) {
QSqlQuery update(database);
update.prepare(QStringLiteral("UPDATE profiles SET folder_path = ? WHERE id = ?"));
update.addBindValue(newPath);
update.addBindValue(id);
if (!update.exec()) {
setLastError(update.lastError().text());
database.rollback();
return false;
}
}
// Reassign (or drop, for the folder itself) affected explicit folder
// markers the same way.
QSqlQuery selectFolders(database);
if (!selectFolders.exec(QStringLiteral("SELECT path FROM profile_folders"))) {
setLastError(selectFolders.lastError().text());
database.rollback();
return false;
}
QStringList affectedFolders;
while (selectFolders.next()) {
const QString path = normalizedFolderPath(selectFolders.value(0).toString());
if (path == normalized || path.startsWith(prefix)) {
affectedFolders.push_back(path);
}
}
for (const QString& oldPath : affectedFolders) {
QSqlQuery remove(database);
remove.prepare(QStringLiteral("DELETE FROM profile_folders WHERE path = ?"));
remove.addBindValue(oldPath);
if (!remove.exec()) {
setLastError(remove.lastError().text());
database.rollback();
return false;
}
const QString newPath = remap(oldPath);
if (oldPath != normalized && !newPath.isEmpty()) {
QSqlQuery insert(database);
insert.prepare(QStringLiteral("INSERT OR IGNORE INTO profile_folders(path) VALUES (?)"));
insert.addBindValue(newPath);
if (!insert.exec()) {
setLastError(insert.lastError().text());
database.rollback();
return false;
}
}
}
if (!database.commit()) {
setLastError(database.lastError().text());
database.rollback();
return false;
}
return true;
}
std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery, std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery,
ProfileSortOrder sortOrder) const ProfileSortOrder sortOrder) const
{ {
@@ -485,7 +616,8 @@ bool ProfileRepository::deleteProfile(qint64 id) const
bool ProfileRepository::initializeDatabase() bool ProfileRepository::initializeDatabase()
{ {
QSqlDatabase database = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName); QSqlDatabase database = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
database.setDatabaseName(buildDatabasePath()); database.setDatabaseName(
m_databasePathOverride.isEmpty() ? buildDatabasePath() : m_databasePathOverride);
if (!database.open()) { if (!database.open()) {
m_initError = database.lastError().text(); m_initError = database.lastError().text();
+5
View File
@@ -35,6 +35,9 @@ class ProfileRepository
{ {
public: public:
ProfileRepository(); 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(); ~ProfileRepository();
QString initError() const; QString initError() const;
@@ -44,6 +47,7 @@ public:
ProfileSortOrder sortOrder = ProfileSortOrder::NameAsc) const; ProfileSortOrder sortOrder = ProfileSortOrder::NameAsc) const;
std::vector<QString> listFolders() const; std::vector<QString> listFolders() const;
bool createFolder(const QString& folderPath) const; bool createFolder(const QString& folderPath) const;
bool deleteFolder(const QString& folderPath) const;
std::optional<Profile> getProfile(qint64 id) const; std::optional<Profile> getProfile(qint64 id) const;
std::optional<Profile> createProfile(const Profile& profile) const; std::optional<Profile> createProfile(const Profile& profile) const;
bool updateProfile(const Profile& profile) const; bool updateProfile(const Profile& profile) const;
@@ -51,6 +55,7 @@ public:
private: private:
QString m_connectionName; QString m_connectionName;
QString m_databasePathOverride;
QString m_initError; QString m_initError;
mutable QString m_lastError; mutable QString m_lastError;
+76
View File
@@ -587,6 +587,8 @@ void ProfilesWindow::showTreeContextMenu(const QPoint& pos)
const QString contextFolder = folderPathForItem(item); const QString contextFolder = folderPathForItem(item);
const bool isProfileItem = item != nullptr && item->data(0, kProfileIdRole).isValid(); const bool isProfileItem = item != nullptr && item->data(0, kProfileIdRole).isValid();
const bool isFolderItem =
item != nullptr && !isProfileItem && item->data(0, kFolderPathRole).isValid();
QMenu menu(this); QMenu menu(this);
QAction* newConnectionAction = menu.addAction(QStringLiteral("New Connection")); QAction* newConnectionAction = menu.addAction(QStringLiteral("New Connection"));
@@ -594,12 +596,16 @@ void ProfilesWindow::showTreeContextMenu(const QPoint& pos)
QAction* connectAction = nullptr; QAction* connectAction = nullptr;
QAction* editAction = nullptr; QAction* editAction = nullptr;
QAction* deleteAction = nullptr; QAction* deleteAction = nullptr;
QAction* deleteFolderAction = nullptr;
if (isProfileItem) { if (isProfileItem) {
menu.addSeparator(); menu.addSeparator();
connectAction = menu.addAction(QStringLiteral("Connect")); connectAction = menu.addAction(QStringLiteral("Connect"));
editAction = menu.addAction(QStringLiteral("Edit")); editAction = menu.addAction(QStringLiteral("Edit"));
deleteAction = menu.addAction(QStringLiteral("Delete")); deleteAction = menu.addAction(QStringLiteral("Delete"));
} else if (isFolderItem) {
menu.addSeparator();
deleteFolderAction = menu.addAction(QStringLiteral("Delete Folder"));
} }
QAction* chosen = menu.exec(m_profilesTree->viewport()->mapToGlobal(pos)); QAction* chosen = menu.exec(m_profilesTree->viewport()->mapToGlobal(pos));
@@ -629,6 +635,10 @@ void ProfilesWindow::showTreeContextMenu(const QPoint& pos)
deleteSelectedProfile(); deleteSelectedProfile();
return; return;
} }
if (isFolderItem && chosen == deleteFolderAction) {
deleteFolderInContext(contextFolder);
return;
}
} }
void ProfilesWindow::createFolderInContext(const QString& baseFolderPath) void ProfilesWindow::createFolderInContext(const QString& baseFolderPath)
@@ -666,6 +676,72 @@ void ProfilesWindow::createFolderInContext(const QString& baseFolderPath)
loadProfiles(); loadProfiles();
} }
void ProfilesWindow::deleteFolderInContext(const QString& folderPath)
{
const QString normalized = normalizeFolderPathForView(folderPath);
if (normalized.isEmpty()) {
return;
}
// Deleting a folder never deletes profiles -- everything inside it
// (profiles and subfolders) shifts up to take its place. Count what's
// affected so the confirmation is honest about that, rather than
// reading like the usual destructive "Delete" action.
const QString prefix = normalized + QStringLiteral("/");
int profileCount = 0;
for (const Profile& profile : m_repository->listProfiles()) {
if (profile.folderPath == normalized || profile.folderPath.startsWith(prefix)) {
++profileCount;
}
}
int subfolderCount = 0;
for (const QString& folder : m_repository->listFolders()) {
if (folder != normalized && folder.startsWith(prefix)) {
++subfolderCount;
}
}
if (profileCount > 0 || subfolderCount > 0) {
QStringList parentParts = splitFolderPath(normalized);
parentParts.removeLast();
const QString destination = parentParts.isEmpty()
? QStringLiteral("the top level")
: QStringLiteral("'%1'").arg(parentParts.join(QStringLiteral("/")));
QStringList details;
if (profileCount > 0) {
details.push_back(QStringLiteral("%1 profile(s)").arg(profileCount));
}
if (subfolderCount > 0) {
details.push_back(QStringLiteral("%1 subfolder(s)").arg(subfolderCount));
}
const QMessageBox::StandardButton confirm = QMessageBox::question(
this,
QStringLiteral("Delete Folder"),
QStringLiteral("Delete folder '%1'?\n\nNothing will be deleted: %2 currently inside "
"it will move up to %3.")
.arg(normalized, details.join(QStringLiteral(" and ")), destination),
QMessageBox::Yes | QMessageBox::No,
QMessageBox::No);
if (confirm != QMessageBox::Yes) {
return;
}
}
if (!m_repository->deleteFolder(normalized)) {
QMessageBox::warning(this,
QStringLiteral("Delete Folder"),
QStringLiteral("Failed to delete folder: %1")
.arg(m_repository->lastError().isEmpty()
? QStringLiteral("unknown error")
: m_repository->lastError()));
return;
}
loadProfiles();
}
void ProfilesWindow::persistFolderAssignmentsFromTree() void ProfilesWindow::persistFolderAssignmentsFromTree()
{ {
if (!isFolderViewEnabled() || m_profilesTree == nullptr) { if (!isFolderViewEnabled() || m_profilesTree == nullptr) {
+1
View File
@@ -65,6 +65,7 @@ private:
QString folderPathForItem(const QTreeWidgetItem* item) const; QString folderPathForItem(const QTreeWidgetItem* item) const;
void showTreeContextMenu(const QPoint& pos); void showTreeContextMenu(const QPoint& pos);
void createFolderInContext(const QString& baseFolderPath); void createFolderInContext(const QString& baseFolderPath);
void deleteFolderInContext(const QString& folderPath);
void persistFolderAssignmentsFromTree(); void persistFolderAssignmentsFromTree();
void collectProfileAssignments(const QTreeWidgetItem* item, void collectProfileAssignments(const QTreeWidgetItem* item,
const QString& parentFolderPath, const QString& parentFolderPath,
+61 -59
View File
@@ -89,59 +89,13 @@ constexpr double kDefaultDpi = 96.0;
constexpr double kMillimetersPerInch = 25.4; constexpr double kMillimetersPerInch = 25.4;
#ifdef ORBITHUB_HAS_FREERDP #ifdef ORBITHUB_HAS_FREERDP
QString normalizedRdpSecurityMode(const QString& value)
{
const QString mode = value.trimmed();
if (mode.compare(QStringLiteral("NLA"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("NLA");
}
if (mode.compare(QStringLiteral("TLS"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("TLS");
}
if (mode.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("RDP");
}
return QStringLiteral("Negotiate");
}
// MS-RDPEDISP restricts DesktopScaleFactor/DeviceScaleFactor to exactly
// these three values; FreeRDP's own reference client enforces the same
// set (client/common/cmdline.c, parse_scale_options). Anything else is
// silently ignored by the server, so map the real, continuous
// devicePixelRatio down to the nearest one.
UINT32 nearestFreeRdpScaleValue(qreal ratio)
{
if (ratio <= 1.2) {
return 100;
}
if (ratio <= 1.6) {
return 140;
}
return 180;
}
QString normalizedRdpPerformanceProfile(const QString& value)
{
const QString profile = value.trimmed();
if (profile.compare(QStringLiteral("Best Quality"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Best Quality");
}
if (profile.compare(QStringLiteral("Best Performance"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Best Performance");
}
if (profile.compare(QStringLiteral("Auto Detect"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Auto Detect");
}
return QStringLiteral("Balanced");
}
bool applyRdpSecurityMode(rdpSettings* settings, const QString& mode) bool applyRdpSecurityMode(rdpSettings* settings, const QString& mode)
{ {
if (settings == nullptr) { if (settings == nullptr) {
return false; return false;
} }
const QString normalized = normalizedRdpSecurityMode(mode); const QString normalized = RdpSessionBackend::normalizedRdpSecurityMode(mode);
BOOL rdp = FALSE; BOOL rdp = FALSE;
BOOL tls = FALSE; BOOL tls = FALSE;
@@ -175,7 +129,7 @@ bool applyRdpPerformanceProfile(rdpSettings* settings, const QString& profile)
return false; return false;
} }
const QString normalized = normalizedRdpPerformanceProfile(profile); const QString normalized = RdpSessionBackend::normalizedRdpPerformanceProfile(profile);
UINT32 connectionType = CONNECTION_TYPE_BROADBAND_HIGH; UINT32 connectionType = CONNECTION_TYPE_BROADBAND_HIGH;
BOOL networkAutoDetect = FALSE; BOOL networkAutoDetect = FALSE;
if (normalized == QStringLiteral("Best Quality")) { if (normalized == QStringLiteral("Best Quality")) {
@@ -875,8 +829,55 @@ BOOL orbitAuthenticateEx(freerdp* instance,
return TRUE; return TRUE;
} }
}
UINT32 scancodeFromNativeScanCode(quint32 nativeScanCode) QString RdpSessionBackend::normalizedRdpSecurityMode(const QString& value)
{
const QString mode = value.trimmed();
if (mode.compare(QStringLiteral("NLA"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("NLA");
}
if (mode.compare(QStringLiteral("TLS"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("TLS");
}
if (mode.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("RDP");
}
return QStringLiteral("Negotiate");
}
// MS-RDPEDISP restricts DesktopScaleFactor/DeviceScaleFactor to exactly
// these three values; FreeRDP's own reference client enforces the same
// set (client/common/cmdline.c, parse_scale_options). Anything else is
// silently ignored by the server, so map the real, continuous
// devicePixelRatio down to the nearest one.
quint32 RdpSessionBackend::nearestFreeRdpScaleValue(qreal ratio)
{
if (ratio <= 1.2) {
return 100;
}
if (ratio <= 1.6) {
return 140;
}
return 180;
}
QString RdpSessionBackend::normalizedRdpPerformanceProfile(const QString& value)
{
const QString profile = value.trimmed();
if (profile.compare(QStringLiteral("Best Quality"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Best Quality");
}
if (profile.compare(QStringLiteral("Best Performance"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Best Performance");
}
if (profile.compare(QStringLiteral("Auto Detect"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Auto Detect");
}
return QStringLiteral("Balanced");
}
quint32 RdpSessionBackend::scancodeFromNativeScanCode(quint32 nativeScanCode)
{ {
if (nativeScanCode == 0) { if (nativeScanCode == 0) {
return RDP_SCANCODE_UNKNOWN; return RDP_SCANCODE_UNKNOWN;
@@ -935,10 +936,12 @@ UINT32 scancodeFromNativeScanCode(quint32 nativeScanCode)
#endif #endif
} }
UINT32 scancodeForQtKey(int key, Qt::KeyboardModifiers modifiers, quint32 nativeScanCode) quint32 RdpSessionBackend::scancodeForQtKey(int key,
Qt::KeyboardModifiers modifiers,
quint32 nativeScanCode)
{ {
const bool keypad = modifiers.testFlag(Qt::KeypadModifier); const bool keypad = modifiers.testFlag(Qt::KeypadModifier);
const UINT32 nativeScancode = scancodeFromNativeScanCode(nativeScanCode); const quint32 nativeScancode = scancodeFromNativeScanCode(nativeScanCode);
switch (key) { switch (key) {
case Qt::Key_Escape: case Qt::Key_Escape:
@@ -1163,7 +1166,7 @@ UINT32 scancodeForQtKey(int key, Qt::KeyboardModifiers modifiers, quint32 native
} }
} }
QString mapRdpError(UINT32 code) QString RdpSessionBackend::mapRdpError(quint32 code)
{ {
switch (code) { switch (code) {
case FREERDP_ERROR_CONNECT_LOGON_FAILURE: case FREERDP_ERROR_CONNECT_LOGON_FAILURE:
@@ -1226,7 +1229,7 @@ QString mapRdpError(UINT32 code)
return QStringLiteral("RDP connection failed (0x%1).").arg(code, 8, 16, QChar('0')); return QStringLiteral("RDP connection failed (0x%1).").arg(code, 8, 16, QChar('0'));
} }
bool isExpectedDisconnectCode(UINT32 code) bool RdpSessionBackend::isExpectedDisconnectCode(quint32 code)
{ {
switch (code) { switch (code) {
case FREERDP_ERROR_SUCCESS: case FREERDP_ERROR_SUCCESS:
@@ -1256,7 +1259,7 @@ bool isExpectedDisconnectCode(UINT32 code)
} }
} }
bool isExpectedConnectAbortCode(UINT32 code) bool RdpSessionBackend::isExpectedConnectAbortCode(quint32 code)
{ {
switch (code) { switch (code) {
case FREERDP_ERROR_SUCCESS: case FREERDP_ERROR_SUCCESS:
@@ -1268,7 +1271,7 @@ bool isExpectedConnectAbortCode(UINT32 code)
} }
} }
QString disconnectMessageForCode(UINT32 code) QString RdpSessionBackend::disconnectMessageForCode(quint32 code)
{ {
switch (code) { switch (code) {
case FREERDP_ERROR_IDLE_TIMEOUT: case FREERDP_ERROR_IDLE_TIMEOUT:
@@ -1304,7 +1307,7 @@ QString disconnectMessageForCode(UINT32 code)
} }
} }
QString rdpErrorRaw(UINT32 code) QString RdpSessionBackend::rdpErrorRaw(quint32 code)
{ {
const char* name = freerdp_get_last_error_name(code); const char* name = freerdp_get_last_error_name(code);
const QString text = (name != nullptr && name[0] != '\0') ? QString::fromUtf8(name) const QString text = (name != nullptr && name[0] != '\0') ? QString::fromUtf8(name)
@@ -1312,7 +1315,6 @@ QString rdpErrorRaw(UINT32 code)
return QStringLiteral("%1 (0x%2)").arg(text).arg(code, 8, 16, QChar('0')); return QStringLiteral("%1 (0x%2)").arg(text).arg(code, 8, 16, QChar('0'));
} }
#endif #endif
}
RdpSessionBackend::RdpSessionBackend(const Profile& profile, QObject* parent) RdpSessionBackend::RdpSessionBackend(const Profile& profile, QObject* parent)
: SessionBackend(profile, parent), : SessionBackend(profile, parent),
@@ -2434,7 +2436,7 @@ void RdpSessionBackend::emitConnectionFailureAsync(const QString& displayMessage
Qt::QueuedConnection); Qt::QueuedConnection);
} }
int RdpSessionBackend::sanitizeDesktopWidth(int width) const int RdpSessionBackend::sanitizeDesktopWidth(int width)
{ {
if (width <= 0) { if (width <= 0) {
return kDefaultDesktopWidth; return kDefaultDesktopWidth;
@@ -2442,7 +2444,7 @@ int RdpSessionBackend::sanitizeDesktopWidth(int width) const
return qBound(kMinDesktopWidth, width, kMaxDesktopWidth); return qBound(kMinDesktopWidth, width, kMaxDesktopWidth);
} }
int RdpSessionBackend::sanitizeDesktopHeight(int height) const int RdpSessionBackend::sanitizeDesktopHeight(int height)
{ {
if (height <= 0) { if (height <= 0) {
return kDefaultDesktopHeight; return kDefaultDesktopHeight;
+18 -2
View File
@@ -19,6 +19,24 @@ public:
explicit RdpSessionBackend(const Profile& profile, QObject* parent = nullptr); explicit RdpSessionBackend(const Profile& profile, QObject* parent = nullptr);
~RdpSessionBackend() override; ~RdpSessionBackend() override;
// Pure, state-free helpers exposed as public statics purely so tests
// can exercise them without a live FreeRDP connection. UINT32 values
// are surfaced as quint32 here to keep FreeRDP/WinPR types out of this
// header (uint32_t is what UINT32 always is on every platform this
// project targets).
static QString normalizedRdpSecurityMode(const QString& value);
static QString normalizedRdpPerformanceProfile(const QString& value);
static quint32 nearestFreeRdpScaleValue(qreal ratio);
static quint32 scancodeFromNativeScanCode(quint32 nativeScanCode);
static quint32 scancodeForQtKey(int key, Qt::KeyboardModifiers modifiers, quint32 nativeScanCode);
static QString mapRdpError(quint32 code);
static bool isExpectedDisconnectCode(quint32 code);
static bool isExpectedConnectAbortCode(quint32 code);
static QString disconnectMessageForCode(quint32 code);
static QString rdpErrorRaw(quint32 code);
static int sanitizeDesktopWidth(int width);
static int sanitizeDesktopHeight(int height);
public slots: public slots:
void connectSession(const SessionConnectOptions& options) override; void connectSession(const SessionConnectOptions& options) override;
void disconnectSession() override; void disconnectSession() override;
@@ -124,8 +142,6 @@ public:
private: private:
void emitStateAsync(SessionState state, const QString& message); void emitStateAsync(SessionState state, const QString& message);
void emitConnectionFailureAsync(const QString& displayMessage, const QString& rawMessage); void emitConnectionFailureAsync(const QString& displayMessage, const QString& rawMessage);
int sanitizeDesktopWidth(int width) const;
int sanitizeDesktopHeight(int height) const;
}; };
#endif #endif
+16 -10
View File
@@ -7,16 +7,14 @@
#include <QTextStream> #include <QTextStream>
#include <QUuid> #include <QUuid>
namespace { SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent)
QString escapeForShellSingleQuotes(const QString& value) : SshSessionBackend(profile, QStringLiteral("ssh"), parent)
{ {
QString escaped = value;
escaped.replace(QStringLiteral("'"), QStringLiteral("'\"'\"'"));
return escaped;
}
} }
SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent) SshSessionBackend::SshSessionBackend(const Profile& profile,
const QString& sshProgramOverride,
QObject* parent)
: SessionBackend(profile, parent), : SessionBackend(profile, parent),
m_process(new QProcess(this)), m_process(new QProcess(this)),
m_connectedProbeTimer(new QTimer(this)), m_connectedProbeTimer(new QTimer(this)),
@@ -27,7 +25,8 @@ SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent)
m_waitingForHostKeyConfirmation(false), m_waitingForHostKeyConfirmation(false),
m_passwordSubmitted(false), m_passwordSubmitted(false),
m_terminalColumns(0), m_terminalColumns(0),
m_terminalRows(0) m_terminalRows(0),
m_sshProgram(sshProgramOverride)
{ {
m_connectedProbeTimer->setSingleShot(true); m_connectedProbeTimer->setSingleShot(true);
@@ -395,7 +394,7 @@ bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options)
args << target; args << target;
m_process->setProcessEnvironment(environment); m_process->setProcessEnvironment(environment);
m_process->setProgram(QStringLiteral("ssh")); m_process->setProgram(m_sshProgram);
m_process->setArguments(args); m_process->setArguments(args);
m_process->setProcessChannelMode(QProcess::SeparateChannels); m_process->setProcessChannelMode(QProcess::SeparateChannels);
@@ -471,7 +470,7 @@ void SshSessionBackend::cleanupAskPassScript()
} }
} }
QString SshSessionBackend::mapSshError(const QString& rawError) const QString SshSessionBackend::mapSshError(const QString& rawError)
{ {
const QString raw = rawError.trimmed(); const QString raw = rawError.trimmed();
if (raw.contains(QStringLiteral("Permission denied"), Qt::CaseInsensitive)) { if (raw.contains(QStringLiteral("Permission denied"), Qt::CaseInsensitive)) {
@@ -510,6 +509,13 @@ QString SshSessionBackend::mapSshError(const QString& rawError) const
return QStringLiteral("SSH connection failed."); return QStringLiteral("SSH connection failed.");
} }
QString SshSessionBackend::escapeForShellSingleQuotes(const QString& value)
{
QString escaped = value;
escaped.replace(QStringLiteral("'"), QStringLiteral("'\"'\"'"));
return escaped;
}
QString SshSessionBackend::knownHostsFileForNullDevice() const QString SshSessionBackend::knownHostsFileForNullDevice() const
{ {
#ifdef Q_OS_WIN #ifdef Q_OS_WIN
+9 -1
View File
@@ -13,8 +13,16 @@ class SshSessionBackend : public SessionBackend
public: public:
explicit SshSessionBackend(const Profile& profile, QObject* parent = nullptr); explicit SshSessionBackend(const Profile& profile, QObject* parent = nullptr);
// Test-only: overrides the executable launched instead of "ssh", so
// tests can point it at a controllable fixture script.
SshSessionBackend(const Profile& profile, const QString& sshProgramOverride, QObject* parent);
~SshSessionBackend() override; ~SshSessionBackend() override;
// Pure, state-free helpers exposed as public statics purely so tests
// can exercise them directly without spinning up a real ssh process.
static QString mapSshError(const QString& rawError);
static QString escapeForShellSingleQuotes(const QString& value);
public slots: public slots:
void connectSession(const SessionConnectOptions& options) override; void connectSession(const SessionConnectOptions& options) override;
void disconnectSession() override; void disconnectSession() override;
@@ -46,6 +54,7 @@ private:
bool m_passwordSubmitted; bool m_passwordSubmitted;
int m_terminalColumns; int m_terminalColumns;
int m_terminalRows; int m_terminalRows;
QString m_sshProgram;
void setState(SessionState state, const QString& message); void setState(SessionState state, const QString& message);
bool startSshProcess(const SessionConnectOptions& options); bool startSshProcess(const SessionConnectOptions& options);
@@ -53,7 +62,6 @@ private:
QProcessEnvironment& environment, QProcessEnvironment& environment,
QString& error); QString& error);
void cleanupAskPassScript(); void cleanupAskPassScript();
QString mapSshError(const QString& rawError) const;
QString knownHostsFileForNullDevice() const; QString knownHostsFileForNullDevice() const;
void applyTerminalSizeIfAvailable(); void applyTerminalSizeIfAvailable();
}; };
+42
View File
@@ -0,0 +1,42 @@
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)
add_executable(test_ssh_session_backend
test_ssh_session_backend.cpp
${CMAKE_SOURCE_DIR}/src/ssh_session_backend.cpp
${CMAKE_SOURCE_DIR}/src/session_backend.h
)
target_include_directories(test_ssh_session_backend PRIVATE ${CMAKE_SOURCE_DIR}/src)
target_link_libraries(test_ssh_session_backend PRIVATE Qt6::Core Qt6::Gui Qt6::Test)
target_compile_definitions(test_ssh_session_backend PRIVATE
ORBITHUB_TEST_FIXTURES_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures"
)
add_test(NAME test_ssh_session_backend COMMAND test_ssh_session_backend)
if(TARGET freerdp AND TARGET winpr)
add_executable(test_rdp_session_backend
test_rdp_session_backend.cpp
${CMAKE_SOURCE_DIR}/src/rdp_session_backend.cpp
${CMAKE_SOURCE_DIR}/src/session_backend.h
)
target_include_directories(test_rdp_session_backend PRIVATE
${CMAKE_SOURCE_DIR}/src
${CMAKE_SOURCE_DIR}/third_party/FreeRDP/include
${CMAKE_SOURCE_DIR}/third_party/FreeRDP/winpr/include
${CMAKE_BINARY_DIR}/third_party/FreeRDP/include
${CMAKE_BINARY_DIR}/third_party/FreeRDP/winpr/include
)
target_compile_definitions(test_rdp_session_backend PRIVATE ORBITHUB_HAS_FREERDP)
target_link_libraries(test_rdp_session_backend PRIVATE Qt6::Core Qt6::Gui Qt6::Test freerdp winpr)
if(TARGET freerdp-client)
target_link_libraries(test_rdp_session_backend PRIVATE freerdp-client)
endif()
add_test(NAME test_rdp_session_backend COMMAND test_rdp_session_backend)
else()
message(STATUS "FreeRDP targets not available -- skipping test_rdp_session_backend")
endif()
Vendored Executable
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
# Minimal, deterministic stand-in for the real `ssh` binary, used by
# SshSessionBackend's state-machine tests so they never touch a real
# network or SSH server. Behavior is selected by which fixture hostname
# appears among argv (SshSessionBackend always passes the profile's
# host, optionally as user@host, as the final argument).
for arg in "$@"; do
case "$arg" in
*@succeed|succeed)
echo "Welcome to the fake host."
# Stay alive echoing stdin back (simulates an interactive
# session) until the backend terminates us.
while IFS= read -r line; do
echo "$line"
done
exit 0
;;
*@fail-auth|fail-auth)
echo "Permission denied (publickey,password)." >&2
exit 255
;;
*@refuse|refuse)
echo "ssh: connect to host refuse port 22: Connection refused" >&2
exit 255
;;
esac
done
echo "fake_ssh.sh: no recognized fixture host in arguments: $*" >&2
exit 1
+375
View File
@@ -0,0 +1,375 @@
#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();
void deleteEmptyFolderRemovesIt();
void deleteFolderMovesDirectProfilesToParent();
void deleteRootLevelFolderMovesProfilesToRoot();
void deleteFolderShiftsSubfoldersAndTheirProfilesUp();
void deleteFolderRejectsEmptyPath();
void deleteNonexistentFolderSucceedsAsNoOp();
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"));
}
void TestProfileRepository::deleteEmptyFolderRemovesIt()
{
QVERIFY(m_repo->createFolder(QStringLiteral("Empty")));
QVERIFY(m_repo->deleteFolder(QStringLiteral("Empty")));
QCOMPARE(m_repo->listFolders().size(), size_t(0));
}
void TestProfileRepository::deleteFolderMovesDirectProfilesToParent()
{
QVERIFY(m_repo->createFolder(QStringLiteral("Work")));
Profile profile = makeSshProfile();
profile.folderPath = QStringLiteral("Work");
const std::optional<Profile> created = m_repo->createProfile(profile);
QVERIFY(created.has_value());
QVERIFY(m_repo->deleteFolder(QStringLiteral("Work")));
QCOMPARE(m_repo->listFolders().size(), size_t(0));
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
QVERIFY(fetched.has_value());
// Deleting a folder is never destructive to profiles -- they shift up
// to take its place, here landing at the root since "Work" had no
// parent of its own.
QCOMPARE(fetched->folderPath, QString());
}
void TestProfileRepository::deleteRootLevelFolderMovesProfilesToRoot()
{
Profile profile = makeRdpProfile();
profile.folderPath = QStringLiteral("Solo");
const std::optional<Profile> created = m_repo->createProfile(profile);
QVERIFY(created.has_value());
QVERIFY(m_repo->deleteFolder(QStringLiteral("Solo")));
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
QVERIFY(fetched.has_value());
QCOMPARE(fetched->folderPath, QString());
}
void TestProfileRepository::deleteFolderShiftsSubfoldersAndTheirProfilesUp()
{
QVERIFY(m_repo->createFolder(QStringLiteral("Work/Servers")));
Profile inTarget = makeSshProfile(QStringLiteral("InWork"));
inTarget.folderPath = QStringLiteral("Work");
const std::optional<Profile> createdInTarget = m_repo->createProfile(inTarget);
QVERIFY(createdInTarget.has_value());
Profile inSubfolder = makeRdpProfile(QStringLiteral("InServers"));
inSubfolder.folderPath = QStringLiteral("Work/Servers");
const std::optional<Profile> createdInSubfolder = m_repo->createProfile(inSubfolder);
QVERIFY(createdInSubfolder.has_value());
QVERIFY(m_repo->deleteFolder(QStringLiteral("Work")));
// "Work/Servers" shifts up to become root-level "Servers"; the profile
// that was directly in "Work" moves to root; nothing is deleted.
const auto folders = m_repo->listFolders();
QCOMPARE(folders.size(), size_t(1));
QCOMPARE(folders[0], QStringLiteral("Servers"));
const std::optional<Profile> fetchedInTarget = m_repo->getProfile(createdInTarget->id);
QVERIFY(fetchedInTarget.has_value());
QCOMPARE(fetchedInTarget->folderPath, QString());
const std::optional<Profile> fetchedInSubfolder = m_repo->getProfile(createdInSubfolder->id);
QVERIFY(fetchedInSubfolder.has_value());
QCOMPARE(fetchedInSubfolder->folderPath, QStringLiteral("Servers"));
}
void TestProfileRepository::deleteFolderRejectsEmptyPath()
{
QVERIFY(!m_repo->deleteFolder(QString()));
QVERIFY(!m_repo->lastError().isEmpty());
}
void TestProfileRepository::deleteNonexistentFolderSucceedsAsNoOp()
{
QVERIFY(m_repo->deleteFolder(QStringLiteral("Never/Created")));
}
QTEST_GUILESS_MAIN(TestProfileRepository)
#include "test_profile_repository.moc"
+281
View File
@@ -0,0 +1,281 @@
#include "rdp_session_backend.h"
#include <QTest>
#include <freerdp/error.h>
#include <freerdp/locale/keyboard.h>
#include <freerdp/scancode.h>
class TestRdpSessionBackend : public QObject
{
Q_OBJECT
private slots:
void normalizedRdpSecurityModeRecognizesKnownValues();
void normalizedRdpSecurityModeFallsBackToNegotiate();
void normalizedRdpPerformanceProfileRecognizesKnownValues();
void normalizedRdpPerformanceProfileFallsBackToBalanced();
void nearestFreeRdpScaleValueMapsToLegalValues();
void sanitizeDesktopWidthClampsToLegalRange();
void sanitizeDesktopHeightClampsToLegalRange();
void scancodeFromNativeScanCodeHandlesZero();
#if defined(Q_OS_LINUX)
void scancodeFromNativeScanCodeDelegatesToX11TableOnLinux();
void scancodeFromNativeScanCodeDoesNotTreatX11KeycodeAsPcAtScancode();
#endif
void scancodeForQtKeyMapsDirectKeys();
void scancodeForQtKeyRespectsKeypadModifier();
void scancodeForQtKeyDisambiguatesLeftRightModifiers();
void scancodeForQtKeyReturnsUnknownForUnhandledKey();
void mapRdpErrorRecognizesAuthFailureCodes();
void mapRdpErrorRecognizesAccountStateCodes();
void mapRdpErrorRecognizesNetworkCodes();
void mapRdpErrorFallsBackForUnknownCode();
void isExpectedDisconnectCodeRecognizesBenignCodes();
void isExpectedDisconnectCodeRejectsAuthFailure();
void isExpectedConnectAbortCodeRecognizesCancellation();
void isExpectedConnectAbortCodeRejectsAuthFailure();
void disconnectMessageForCodeRecognizesKnownCodes();
void disconnectMessageForCodeFallsBackForUnknownCode();
void rdpErrorRawIncludesHexCode();
};
void TestRdpSessionBackend::normalizedRdpSecurityModeRecognizesKnownValues()
{
QCOMPARE(RdpSessionBackend::normalizedRdpSecurityMode(QStringLiteral("nla")),
QStringLiteral("NLA"));
QCOMPARE(RdpSessionBackend::normalizedRdpSecurityMode(QStringLiteral(" TLS ")),
QStringLiteral("TLS"));
QCOMPARE(RdpSessionBackend::normalizedRdpSecurityMode(QStringLiteral("Rdp")),
QStringLiteral("RDP"));
}
void TestRdpSessionBackend::normalizedRdpSecurityModeFallsBackToNegotiate()
{
QCOMPARE(RdpSessionBackend::normalizedRdpSecurityMode(QStringLiteral("bogus")),
QStringLiteral("Negotiate"));
QCOMPARE(RdpSessionBackend::normalizedRdpSecurityMode(QString()),
QStringLiteral("Negotiate"));
}
void TestRdpSessionBackend::normalizedRdpPerformanceProfileRecognizesKnownValues()
{
QCOMPARE(RdpSessionBackend::normalizedRdpPerformanceProfile(QStringLiteral("best quality")),
QStringLiteral("Best Quality"));
QCOMPARE(RdpSessionBackend::normalizedRdpPerformanceProfile(QStringLiteral(" Best Performance ")),
QStringLiteral("Best Performance"));
QCOMPARE(RdpSessionBackend::normalizedRdpPerformanceProfile(QStringLiteral("auto detect")),
QStringLiteral("Auto Detect"));
}
void TestRdpSessionBackend::normalizedRdpPerformanceProfileFallsBackToBalanced()
{
QCOMPARE(RdpSessionBackend::normalizedRdpPerformanceProfile(QStringLiteral("bogus")),
QStringLiteral("Balanced"));
}
void TestRdpSessionBackend::nearestFreeRdpScaleValueMapsToLegalValues()
{
// MS-RDPEDISP legally permits only {100, 140, 180} -- anything else is
// silently ignored by the server.
QCOMPARE(RdpSessionBackend::nearestFreeRdpScaleValue(1.0), quint32(100));
QCOMPARE(RdpSessionBackend::nearestFreeRdpScaleValue(1.2), quint32(100));
QCOMPARE(RdpSessionBackend::nearestFreeRdpScaleValue(1.25), quint32(140));
QCOMPARE(RdpSessionBackend::nearestFreeRdpScaleValue(1.6), quint32(140));
QCOMPARE(RdpSessionBackend::nearestFreeRdpScaleValue(2.0), quint32(180));
QCOMPARE(RdpSessionBackend::nearestFreeRdpScaleValue(3.0), quint32(180));
}
void TestRdpSessionBackend::sanitizeDesktopWidthClampsToLegalRange()
{
QCOMPARE(RdpSessionBackend::sanitizeDesktopWidth(0), 1280);
QCOMPARE(RdpSessionBackend::sanitizeDesktopWidth(-100), 1280);
QCOMPARE(RdpSessionBackend::sanitizeDesktopWidth(100), 640);
QCOMPARE(RdpSessionBackend::sanitizeDesktopWidth(1920), 1920);
QCOMPARE(RdpSessionBackend::sanitizeDesktopWidth(99999), 8192);
}
void TestRdpSessionBackend::sanitizeDesktopHeightClampsToLegalRange()
{
QCOMPARE(RdpSessionBackend::sanitizeDesktopHeight(0), 720);
QCOMPARE(RdpSessionBackend::sanitizeDesktopHeight(-100), 720);
QCOMPARE(RdpSessionBackend::sanitizeDesktopHeight(100), 360);
QCOMPARE(RdpSessionBackend::sanitizeDesktopHeight(1080), 1080);
QCOMPARE(RdpSessionBackend::sanitizeDesktopHeight(99999), 4320);
}
void TestRdpSessionBackend::scancodeFromNativeScanCodeHandlesZero()
{
QCOMPARE(RdpSessionBackend::scancodeFromNativeScanCode(0), quint32(RDP_SCANCODE_UNKNOWN));
}
#if defined(Q_OS_LINUX)
void TestRdpSessionBackend::scancodeFromNativeScanCodeDelegatesToX11TableOnLinux()
{
// Our wrapper must be a faithful passthrough to FreeRDP's own
// authoritative X11-keycode table, not a reimplementation of it.
const quint32 apostropheKeycode = 0x30;
#if defined(__GNUC__) || defined(__clang__)
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
#endif
const quint32 expected =
static_cast<quint32>(freerdp_keyboard_get_rdp_scancode_from_x11_keycode(apostropheKeycode));
#if defined(__GNUC__) || defined(__clang__)
#pragma GCC diagnostic pop
#endif
QCOMPARE(RdpSessionBackend::scancodeFromNativeScanCode(apostropheKeycode), expected);
}
void TestRdpSessionBackend::scancodeFromNativeScanCodeDoesNotTreatX11KeycodeAsPcAtScancode()
{
// Regression guard for the historical bug this table replaced: X11
// keycode 0x30 (apostrophe/quote) must NOT resolve to whatever a naive
// "treat the X11 keycode as a PC/AT set-1 scancode" interpretation
// would give (PC/AT 0x30 is the B key).
const quint32 apostropheKeycode = 0x30;
const quint32 naivePcAtInterpretation = MAKE_RDP_SCANCODE(apostropheKeycode, FALSE);
QVERIFY(RdpSessionBackend::scancodeFromNativeScanCode(apostropheKeycode)
!= naivePcAtInterpretation);
}
#endif
void TestRdpSessionBackend::scancodeForQtKeyMapsDirectKeys()
{
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Escape, Qt::NoModifier, 0),
quint32(RDP_SCANCODE_ESCAPE));
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_A, Qt::NoModifier, 0),
quint32(RDP_SCANCODE_KEY_A));
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_F1, Qt::NoModifier, 0),
quint32(RDP_SCANCODE_F1));
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Space, Qt::NoModifier, 0),
quint32(RDP_SCANCODE_SPACE));
}
void TestRdpSessionBackend::scancodeForQtKeyRespectsKeypadModifier()
{
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Insert, Qt::NoModifier, 0),
quint32(RDP_SCANCODE_INSERT));
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Insert, Qt::KeypadModifier, 0),
quint32(RDP_SCANCODE_NUMPAD0));
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Delete, Qt::NoModifier, 0),
quint32(RDP_SCANCODE_DELETE));
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Delete, Qt::KeypadModifier, 0),
quint32(RDP_SCANCODE_DECIMAL));
}
void TestRdpSessionBackend::scancodeForQtKeyDisambiguatesLeftRightModifiers()
{
// With no reliable native scancode (0 -> RDP_SCANCODE_UNKNOWN, which
// matches neither side), both Shift and Control must default to their
// left variant rather than picking arbitrarily.
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Shift, Qt::NoModifier, 0),
quint32(RDP_SCANCODE_LSHIFT));
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Control, Qt::NoModifier, 0),
quint32(RDP_SCANCODE_LCONTROL));
}
void TestRdpSessionBackend::scancodeForQtKeyReturnsUnknownForUnhandledKey()
{
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_MediaPlay, Qt::NoModifier, 0),
quint32(RDP_SCANCODE_UNKNOWN));
}
void TestRdpSessionBackend::mapRdpErrorRecognizesAuthFailureCodes()
{
const QString expected = QStringLiteral("Authentication failed. Check username and password.");
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_LOGON_FAILURE), expected);
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_WRONG_PASSWORD), expected);
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_ACCESS_DENIED), expected);
}
void TestRdpSessionBackend::mapRdpErrorRecognizesAccountStateCodes()
{
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_ACCOUNT_DISABLED),
QStringLiteral("Account is disabled."));
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_ACCOUNT_LOCKED_OUT),
QStringLiteral("Account is locked out."));
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_ACCOUNT_EXPIRED),
QStringLiteral("Account has expired."));
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_PASSWORD_EXPIRED),
QStringLiteral("Password has expired."));
}
void TestRdpSessionBackend::mapRdpErrorRecognizesNetworkCodes()
{
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_DNS_NAME_NOT_FOUND),
QStringLiteral("Host could not be resolved."));
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_TRANSPORT_FAILED),
QStringLiteral("Network transport failed while connecting."));
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_SECURITY_NEGO_CONNECT_FAILED),
QStringLiteral("RDP security negotiation failed. Try a different RDP security mode."));
}
void TestRdpSessionBackend::mapRdpErrorFallsBackForUnknownCode()
{
// Not a code mapRdpError special-cases; must still return something
// non-empty rather than crashing or returning an empty string.
const QString result = RdpSessionBackend::mapRdpError(0x7FFFFFFF);
QVERIFY(!result.isEmpty());
}
void TestRdpSessionBackend::isExpectedDisconnectCodeRecognizesBenignCodes()
{
QVERIFY(RdpSessionBackend::isExpectedDisconnectCode(FREERDP_ERROR_SUCCESS));
QVERIFY(RdpSessionBackend::isExpectedDisconnectCode(FREERDP_ERROR_NONE));
QVERIFY(RdpSessionBackend::isExpectedDisconnectCode(FREERDP_ERROR_LOGOFF_BY_USER));
QVERIFY(RdpSessionBackend::isExpectedDisconnectCode(FREERDP_ERROR_IDLE_TIMEOUT));
}
void TestRdpSessionBackend::isExpectedDisconnectCodeRejectsAuthFailure()
{
// An authentication failure must be treated as a real error, never as
// an expected/benign disconnect -- otherwise the user would see no
// error message at all for a failed login.
QVERIFY(!RdpSessionBackend::isExpectedDisconnectCode(FREERDP_ERROR_CONNECT_LOGON_FAILURE));
}
void TestRdpSessionBackend::isExpectedConnectAbortCodeRecognizesCancellation()
{
QVERIFY(RdpSessionBackend::isExpectedConnectAbortCode(FREERDP_ERROR_CONNECT_CANCELLED));
QVERIFY(RdpSessionBackend::isExpectedConnectAbortCode(FREERDP_ERROR_SUCCESS));
}
void TestRdpSessionBackend::isExpectedConnectAbortCodeRejectsAuthFailure()
{
QVERIFY(!RdpSessionBackend::isExpectedConnectAbortCode(FREERDP_ERROR_CONNECT_LOGON_FAILURE));
}
void TestRdpSessionBackend::disconnectMessageForCodeRecognizesKnownCodes()
{
QCOMPARE(RdpSessionBackend::disconnectMessageForCode(FREERDP_ERROR_IDLE_TIMEOUT),
QStringLiteral("RDP session disconnected due to idle timeout."));
QCOMPARE(RdpSessionBackend::disconnectMessageForCode(FREERDP_ERROR_LOGOFF_BY_USER),
QStringLiteral("RDP session signed out."));
QCOMPARE(RdpSessionBackend::disconnectMessageForCode(FREERDP_ERROR_CONNECT_CANCELLED),
QStringLiteral("Connection cancelled."));
}
void TestRdpSessionBackend::disconnectMessageForCodeFallsBackForUnknownCode()
{
QCOMPARE(RdpSessionBackend::disconnectMessageForCode(0x7FFFFFFF),
QStringLiteral("RDP session ended."));
}
void TestRdpSessionBackend::rdpErrorRawIncludesHexCode()
{
const QString result = RdpSessionBackend::rdpErrorRaw(FREERDP_ERROR_SUCCESS);
QVERIFY(result.contains(QStringLiteral("(0x00000000)")));
}
QTEST_GUILESS_MAIN(TestRdpSessionBackend)
#include "test_rdp_session_backend.moc"
+236
View File
@@ -0,0 +1,236 @@
#include "ssh_session_backend.h"
#include <QTest>
#include <memory>
#ifndef ORBITHUB_TEST_FIXTURES_DIR
#error "ORBITHUB_TEST_FIXTURES_DIR must be defined by the build"
#endif
namespace {
Profile makeProfile(const QString& fixtureHost)
{
Profile profile;
profile.name = QStringLiteral("Test Profile");
profile.host = fixtureHost;
profile.port = 22;
profile.username = QStringLiteral("tester");
profile.protocol = QStringLiteral("SSH");
profile.authMode = QStringLiteral("Password");
return profile;
}
SessionConnectOptions makeOptions()
{
SessionConnectOptions options;
options.password = QStringLiteral("dummy-password");
return options;
}
}
class TestSshSessionBackend : public QObject
{
Q_OBJECT
private slots:
// Pure-function coverage -- no process involved.
void mapSshErrorRecognizesKnownPatterns();
void mapSshErrorFallsBackForUnknownText();
void mapSshErrorHandlesEmptyInput();
void escapeForShellSingleQuotesNeutralizesQuotes();
void escapeForShellSingleQuotesLeavesPlainTextAlone();
// State-machine coverage, driven against tests/fixtures/fake_ssh.sh
// instead of a real ssh binary or network.
void init();
void cleanup();
void successfulConnectReachesConnectedThenDisconnects();
void authFailureReachesFailedStateWithMappedMessage();
void connectionRefusedReachesFailedState();
void sendInputEchoesThroughOutputReceived();
void reconnectRestartsAndReachesConnectedAgain();
private:
QString fixturePath() const;
void createBackend(const QString& fixtureHost);
std::unique_ptr<SshSessionBackend> m_backend;
SessionState m_lastState = SessionState::Disconnected;
QString m_lastErrorDisplay;
QString m_lastErrorRaw;
QString m_receivedOutput;
};
QString TestSshSessionBackend::fixturePath() const
{
return QStringLiteral(ORBITHUB_TEST_FIXTURES_DIR "/fake_ssh.sh");
}
void TestSshSessionBackend::createBackend(const QString& fixtureHost)
{
m_backend =
std::make_unique<SshSessionBackend>(makeProfile(fixtureHost), fixturePath(), nullptr);
connect(m_backend.get(),
&SessionBackend::stateChanged,
this,
[this](SessionState state, const QString&) { m_lastState = state; });
connect(m_backend.get(),
&SessionBackend::connectionError,
this,
[this](const QString& display, const QString& raw) {
m_lastErrorDisplay = display;
m_lastErrorRaw = raw;
});
connect(m_backend.get(),
&SessionBackend::outputReceived,
this,
[this](const QString& chunk) { m_receivedOutput += chunk; });
}
void TestSshSessionBackend::mapSshErrorRecognizesKnownPatterns()
{
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("Permission denied (publickey,password).")),
QStringLiteral("Authentication failed. Check username and credentials."));
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("Host key verification failed.")),
QStringLiteral("Host key verification failed."));
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("ssh: Could not resolve hostname bogus")),
QStringLiteral("Host could not be resolved."));
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("ssh: connect to host x port 22: Connection timed out")),
QStringLiteral("Connection timed out."));
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("ssh: connect to host x port 22: Connection refused")),
QStringLiteral("Connection refused by remote host."));
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("ssh: connect to host x port 22: No route to host")),
QStringLiteral("No route to host."));
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("Identity file /nope not accessible: No such file.")),
QStringLiteral("Private key file is not accessible."));
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("posix_spawn: /usr/bin/ssh-askpass: No such file or directory")),
QStringLiteral("SSH password helper is missing or failed to launch."));
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("open /some/other/path: No such file or directory")),
QStringLiteral("Required file was not found."));
}
void TestSshSessionBackend::mapSshErrorFallsBackForUnknownText()
{
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("some completely novel ssh error text")),
QStringLiteral("SSH connection failed."));
}
void TestSshSessionBackend::mapSshErrorHandlesEmptyInput()
{
QCOMPARE(SshSessionBackend::mapSshError(QString()),
QStringLiteral("SSH connection failed for an unknown reason."));
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral(" ")),
QStringLiteral("SSH connection failed for an unknown reason."));
}
void TestSshSessionBackend::escapeForShellSingleQuotesNeutralizesQuotes()
{
// A password containing a single quote must not be able to break out
// of the single-quoted printf argument in the askpass script -- this
// is the actual security boundary, not just cosmetic escaping.
const QString malicious = QStringLiteral("pw' ; rm -rf ~ ; echo '");
const QString escaped = SshSessionBackend::escapeForShellSingleQuotes(malicious);
const QString reconstructedScriptArg = QStringLiteral("'") + escaped + QStringLiteral("'");
// Every single quote in the reconstructed argument must be either the
// outer boundary quote (open at index 0, close at the very end) or the
// start of a full '"'"' re-opening sequence -- never a bare, unescaped
// quote that could close the argument early.
int index = 0;
while (index < reconstructedScriptArg.length()) {
if (reconstructedScriptArg.at(index) != QChar::fromLatin1('\'')) {
++index;
continue;
}
if (index == 0 || index == reconstructedScriptArg.length() - 1) {
++index;
continue;
}
QCOMPARE(reconstructedScriptArg.mid(index, 5), QStringLiteral("'\"'\"'"));
index += 5;
}
}
void TestSshSessionBackend::escapeForShellSingleQuotesLeavesPlainTextAlone()
{
QCOMPARE(SshSessionBackend::escapeForShellSingleQuotes(QStringLiteral("plain-password-123")),
QStringLiteral("plain-password-123"));
}
void TestSshSessionBackend::init()
{
#ifdef Q_OS_WIN
// fixtures/fake_ssh.sh is a POSIX shell script; there's no Windows
// fixture yet, so skip only the tests that actually launch it. The
// pure-function tests above (mapSshError*, escapeForShellSingleQuotes*)
// don't touch the fixture and still run everywhere.
const QByteArray currentTest = QTest::currentTestFunction();
if (!currentTest.startsWith("mapSshError") && !currentTest.startsWith("escapeForShellSingleQuotes")) {
QSKIP("No Windows equivalent of tests/fixtures/fake_ssh.sh yet");
}
#endif
m_lastState = SessionState::Disconnected;
m_lastErrorDisplay.clear();
m_lastErrorRaw.clear();
m_receivedOutput.clear();
// Individual tests call createBackend() with the fixture host they
// need; most want "succeed", so provide it as the default here.
createBackend(QStringLiteral("succeed"));
}
void TestSshSessionBackend::cleanup()
{
if (m_backend) {
m_backend->disconnectSession();
}
m_backend.reset();
}
void TestSshSessionBackend::successfulConnectReachesConnectedThenDisconnects()
{
m_backend->connectSession(makeOptions());
QTRY_COMPARE(m_lastState, SessionState::Connected);
m_backend->disconnectSession();
QTRY_COMPARE(m_lastState, SessionState::Disconnected);
}
void TestSshSessionBackend::authFailureReachesFailedStateWithMappedMessage()
{
createBackend(QStringLiteral("fail-auth"));
m_backend->connectSession(makeOptions());
QTRY_COMPARE(m_lastState, SessionState::Failed);
QCOMPARE(m_lastErrorDisplay, QStringLiteral("Authentication failed. Check username and credentials."));
QVERIFY(m_lastErrorRaw.contains(QStringLiteral("Permission denied")));
}
void TestSshSessionBackend::connectionRefusedReachesFailedState()
{
createBackend(QStringLiteral("refuse"));
m_backend->connectSession(makeOptions());
QTRY_COMPARE(m_lastState, SessionState::Failed);
QCOMPARE(m_lastErrorDisplay, QStringLiteral("Connection refused by remote host."));
}
void TestSshSessionBackend::sendInputEchoesThroughOutputReceived()
{
m_backend->connectSession(makeOptions());
QTRY_COMPARE(m_lastState, SessionState::Connected);
m_backend->sendInput(QStringLiteral("hello-from-test\n"));
QTRY_VERIFY(m_receivedOutput.contains(QStringLiteral("hello-from-test")));
}
void TestSshSessionBackend::reconnectRestartsAndReachesConnectedAgain()
{
m_backend->connectSession(makeOptions());
QTRY_COMPARE(m_lastState, SessionState::Connected);
m_lastState = SessionState::Connecting;
m_backend->reconnectSession(makeOptions());
QTRY_COMPARE(m_lastState, SessionState::Connected);
}
QTEST_GUILESS_MAIN(TestSshSessionBackend)
#include "test_ssh_session_backend.moc"