diff --git a/.github/actions/deploy-aur/action.yml b/.github/actions/deploy-aur/action.yml index d4cdadb..aeb1e97 100644 --- a/.github/actions/deploy-aur/action.yml +++ b/.github/actions/deploy-aur/action.yml @@ -87,11 +87,19 @@ runs: cp "$GITHUB_WORKSPACE/${{ inputs.pkgbuild }}" ./PKGBUILD + if [[ "${{ inputs.updpkgsums }}" == "true" ]]; then + if command -v updpkgsums >/dev/null 2>&1; then + updpkgsums PKGBUILD + else + echo "::warning::updpkgsums requested but pacman-contrib is not installed in the fallback environment; sha256sums left unchanged" + fi + fi + if [[ "${{ inputs.regenerate_srcinfo }}" == "true" ]]; then docker run --rm -v "$(pwd)":/pkg archlinux:latest \ bash -c "useradd -m builder \ && cp /pkg/PKGBUILD /home/builder/ \ - && su builder -c 'cd /home/builder && makepkg --printsrcinfo >> /pkg/.SRCINFO'" + && su builder -c 'cd /home/builder && makepkg --printsrcinfo > /pkg/.SRCINFO'" fi git config user.name "${{ inputs.commit_username }}" diff --git a/.github/actions/determine-version/action.yml b/.github/actions/determine-version/action.yml index ae0f8e9..9060a79 100644 --- a/.github/actions/determine-version/action.yml +++ b/.github/actions/determine-version/action.yml @@ -1,17 +1,21 @@ name: Determine Version -description: Extract and validate a semantic version from Directory.Build.props and optionally validate against a branch or tag name +description: Extract and validate a semantic version from project inputs: file-name: description: The filename where the dotnet msbuild command can extract the version. Provide the full path from repo root! required: false - default: 'Directory.Build.props' + default: 'OpenSSH_GUI/OpenSSH_GUI.csproj' property: description: The property where the dotnet msbuild command must look for the verison required: false - default: 'BaseVersion' - + default: 'Version' + + suffix: + description: The Suffix to use, when any + required: false + default: '' outputs: version: @@ -25,31 +29,34 @@ runs: using: composite steps: - - name: Extract BaseVersion + - name: Extract Version id: extract shell: bash run: | set -euo pipefail FILE="${{ inputs.file-name }}" + SUFFIX="${{ inputs.suffix }}" if [[ ! -f "$FILE" ]]; then echo "::error::$FILE not found" exit 1 fi - VERSION="$(dotnet msbuild "$FILE" -nologo -getProperty:${{ inputs.property }} | tr -d '\r')" + BUILD_VERSION="$(dotnet msbuild "$FILE" -nologo -getProperty:${{ inputs.property }} -p:VersionSuffix="$SUFFIX" | tr -d '\r')" - if [[ -z "$VERSION" ]]; then + if [[ -z "$BUILD_VERSION" ]]; then echo "::error::${{ inputs.property }} not found in $FILE" exit 1 fi + VERSION="${BUILD_VERSION%%-*}" + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "::error::Invalid Property ${{ inputs.property }}: $VERSION" exit 1 fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "version=$BUILD_VERSION" >> "$GITHUB_OUTPUT" echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" - echo "::notice::Detected version: $VERSION (tag: v$VERSION)" \ No newline at end of file + echo "::notice::Detected version: $BUILD_VERSION (tag: v$VERSION)" diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml index 36e9f4a..0f5854f 100644 --- a/.github/workflows/auto-tag.yml +++ b/.github/workflows/auto-tag.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout merge commit - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 ref: ${{ github.event.pull_request.merge_commit_sha }} diff --git a/.github/workflows/build-and-package.yml b/.github/workflows/build-and-package.yml index d8ddf24..34bcfd8 100644 --- a/.github/workflows/build-and-package.yml +++ b/.github/workflows/build-and-package.yml @@ -29,7 +29,7 @@ jobs: steps: - name: Checkout Repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup .NET environment uses: actions/setup-dotnet@v5 @@ -41,28 +41,9 @@ jobs: **/Directory.Packages.props **/Directory.Build.props - - name: Normalize version and build flags - id: meta - shell: bash - run: | - set -euo pipefail - VERSION="${{ inputs.version }}" - VERSION="${VERSION#v}" - echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" - - name: Install/Restore dependencies shell: bash run: dotnet restore --runtime "${{ matrix.target }}" - - - name: Build application - shell: bash - run: | - set -euo pipefail - dotnet build OpenSSH_GUI/OpenSSH_GUI.csproj \ - --configuration Release \ - --no-restore \ - --runtime "${{ matrix.target }}" \ - -p:Version="${{ steps.meta.outputs.VERSION }}" - name: Publish application shell: bash @@ -75,7 +56,7 @@ jobs: -p:PublishSingleFile=true \ -p:PublishReadyToRun=true \ -p:IncludeNativeLibrariesForSelfExtract=true \ - -p:Version="${{ steps.meta.outputs.VERSION }}" + -p:Version="${{ inputs.version }}" - name: Rename artifact for distribution id: rename @@ -97,7 +78,7 @@ jobs: uses: ./.github/actions/build-appimage with: binary-path: ./publish/${{ steps.rename.outputs.ASSET_NAME }} - version: ${{ steps.meta.outputs.VERSION }} + version: ${{ inputs.version }} asset-prefix: ${{ inputs.asset_name_prefix }} - name: Upload AppImage diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 48d2b8e..266d51a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,8 +4,8 @@ on: push: tags: - 'v[0-9]*.[0-9]*.[0-9]*' - workflow_dispatch: - + branches: + - master permissions: contents: write @@ -29,7 +29,7 @@ jobs: tag: ${{ steps.version.outputs.tag }} steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: '1' ref: ${{ github.ref }} @@ -50,11 +50,15 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 needs: [test, prepare, build] + if: startsWith(github.ref, 'refs/tags/') steps: - name: Checkout Repository - uses: actions/checkout@v6 - + uses: actions/checkout@v7 + with: + fetch-depth: '1' + ref: ${{ github.ref }} + - name: Download Windows binary uses: actions/download-artifact@v8 with: @@ -105,10 +109,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 needs: [test, prepare, build, create_installer] + if: startsWith(github.ref, 'refs/tags/') steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Download all build artifacts uses: actions/download-artifact@v8 @@ -171,4 +176,4 @@ jobs: with: event-type: deploy token: ${{ secrets.PAT_TOKEN }} - client_payload: '{"tag": "${{ needs.prepare.outputs.tag }}"}' \ No newline at end of file + client-payload: '{"tag": "${{ needs.prepare.outputs.tag }}", "version": "${{ needs.prepare.outputs.version }}"}' \ No newline at end of file diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index 7bd6fa0..9c5de6f 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -3,58 +3,27 @@ name: Deploy release to consumers on: repository_dispatch: types: [deploy] - workflow_dispatch: - inputs: - tag: - description: 'Release tag (e.g. v1.2.3)' - required: true - type: string permissions: contents: read jobs: - get_version: - name: Extract Version - runs-on: ubuntu-latest - outputs: - version: ${{ steps.parse.outputs.version }} - tag: ${{ steps.parse.outputs.tag }} - - steps: - - name: Parse release metadata - id: parse - run: | - set -euo pipefail - - TAG="${{ github.event.client_payload.tag || inputs.tag }}" - - if [[ -z "$TAG" ]]; then - echo "::error::No tag available — triggered via workflow_dispatch without tag input" - exit 1 - fi - - VERSION="${TAG#v}" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - aur_publish: name: Publish AUR packages runs-on: ubuntu-latest timeout-minutes: 10 - needs: get_version steps: - name: Checkout PKGBUILD only - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: - ref: ${{ github.event.release.tag_name || inputs.tag }} + ref: ${{ github.event.client_payload.tag }} - name: Update PKGBUILD version run: | set -euo pipefail - VERSION="${{ needs.get_version.outputs.version }}" + VERSION="${{ github.event.client_payload.version }}" sed -i "s/^pkgver=.*/pkgver=$VERSION/" openssh-gui-bin/PKGBUILD sed -i "s/^pkgrel=.*/pkgrel=1/" openssh-gui-bin/PKGBUILD @@ -67,7 +36,7 @@ jobs: ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} commit_username: ${{ github.repository_owner }} commit_email: ${{ github.repository_owner }}@users.noreply.github.com - commit_message: "Update to ${{ needs.get_version.outputs.tag }}" + commit_message: "Update to ${{ github.event.client_payload.tag }}" force_push: 'false' regenerate_srcinfo: 'false' updpkgsums: 'true' @@ -75,7 +44,7 @@ jobs: winget_publish: name: Update Winget Package runs-on: ubuntu-slim - needs: [aur_publish, get_version] + needs: aur_publish timeout-minutes: 10 steps: - name: Update Winget manifest @@ -84,7 +53,7 @@ jobs: max-versions-to-keep: 5 identifier: frequency403.OpenSSHGUI fork-user: ${{ github.repository_owner }} - version: ${{ needs.get_version.outputs.version }} - release-tag: ${{ needs.get_version.outputs.tag }} + version: ${{ github.event.client_payload.version }} + release-tag: ${{ github.event.client_payload.tag }} installers-regex: 'Setup\.exe$' token: ${{ secrets.WINGET_PUBLISH_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index f953da4..e95fa7e 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -1,72 +1,33 @@ -name: Staging / Development Build - +name: staging.yml on: push: branches: - - development - -permissions: - contents: write - + - 'development' + paths: + - 'openssh-gui-git/PKGBUILD' concurrency: group: staging-${{ github.ref }} cancel-in-progress: true - jobs: - test: - name: Run Tests - uses: ./.github/workflows/test.yml - - prepare: - name: Prepare Metadata + aur_publish: + name: Update GIT AUR package runs-on: ubuntu-latest - timeout-minutes: 5 - needs: test - outputs: - version: ${{ steps.version.outputs.version }} - tag: ${{ steps.version.outputs.tag }} - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Determine Version - id: version - uses: ./.github/actions/determine-version - - deploy-aur-git: - name: Update AUR Git Package - runs-on: ubuntu-latest - timeout-minutes: 15 - needs: [ test, prepare ] - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Update PKGBUILD - run: | - set -euo pipefail - DATE=$(date +%Y%m%d) - HASH=$(git rev-parse --short HEAD) - AUR_VERSION="${{ needs.prepare.outputs.version }}.${DATE}.${HASH}" - - sed -i "s/^pkgver=.*/pkgver=$AUR_VERSION/" openssh-gui-git/PKGBUILD - sed -i "s/^pkgrel=.*/pkgrel=1/" openssh-gui-git/PKGBUILD - - echo "Updated PKGBUILD:" - grep -E '^(pkgver=|pkgrel=)' openssh-gui-git/PKGBUILD - - - name: Deploy to AUR - uses: ./.github/actions/deploy-aur - with: - pkgname: openssh-gui-git - pkgbuild: openssh-gui-git/PKGBUILD - ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} - commit_username: ${{ github.repository_owner }} - commit_email: ${{ github.repository_owner }}@users.noreply.github.com - commit_message: "Development update ${{ needs.prepare.outputs.tag }}" - force_push: 'true' - regenerate_srcinfo: 'true' \ No newline at end of file + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: '1' + sparse-checkout: | + openssh-gui-git/PKGBUILD + ref: ${{ github.ref }} + - name: Deploy + uses: KSXGitHub/github-actions-deploy-aur@v4.1.3 + with: + pkgname: openssh-gui-git + pkgbuild: openssh-gui-git/PKGBUILD + commit_username: ${{ github.repository_owner }} + commit_email: ${{ github.repository_owner }}@users.noreply.github.com + ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + commit_message: ${{ github.event.head_commit.message }} + allow_empty_commits: true \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2b9a5d5..76f7331 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,30 +1,31 @@ name: Unit Tests - on: - push: - branches: - - master - - main - pull_request: - branches: - - master - - main - - development workflow_call: + pull_request: + types: + - opened + - reopened + - synchronize + concurrency: group: tests-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: true jobs: test: - name: Run Tests - runs-on: ubuntu-latest + name: ${{matrix.os}}-tests + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ ubuntu-latest, windows-latest, macOS-latest ] timeout-minutes: 20 steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 + with: + ref: ${{ github.ref }} - name: Setup .NET environment uses: actions/setup-dotnet@v5 diff --git a/.github/workflows/version-guard.yml b/.github/workflows/version-guard.yml index a184f9e..a69e16c 100644 --- a/.github/workflows/version-guard.yml +++ b/.github/workflows/version-guard.yml @@ -13,10 +13,13 @@ on: jobs: validate-version: runs-on: ubuntu-latest + if: startsWith(github.event.pull_request.head.ref, 'release/') + outputs: + version: ${{ steps.release_version.outputs.version }} steps: - name: Checkout target (master) - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: master fetch-depth: 0 @@ -24,12 +27,13 @@ jobs: - name: Extract master version id: master_version uses: ./.github/actions/determine-version - + - name: Checkout PR branch - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: - ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - name: Extract release version @@ -45,7 +49,7 @@ jobs: echo "Master: $MASTER" echo "Release: $RELEASE" - python3 - << 'EOF' + python3 - << 'PYEOF' import sys from packaging.version import Version @@ -57,4 +61,4 @@ jobs: sys.exit(1) print("Version check passed") - EOF \ No newline at end of file + PYEOF \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index b7cb11c..4851674 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,10 +5,11 @@ enable default https://github.com/frequency403/OpenSSH-GUI - 3.2.3 + 3.2.4 true + false - + <_Parameter1>ProjectUrl diff --git a/Directory.Build.targets b/Directory.Build.targets deleted file mode 100644 index 35e4484..0000000 --- a/Directory.Build.targets +++ /dev/null @@ -1,13 +0,0 @@ - - - - - 1.0.0 - - $(BaseVersion) - $(BaseVersion) - - - - \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 253ac32..82606a2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,15 +5,16 @@ $(NoWarn);NU1507 - - - - - - - + + + + + + + + - + @@ -25,7 +26,7 @@ - + @@ -40,12 +41,12 @@ - + - + diff --git a/OpenSSH_GUI.Core/Lib/Keys/BasicSshKeyFileInformation.cs b/OpenSSH_GUI.Core/Lib/Keys/BasicSshKeyFileInformation.cs index 471d551..b16aee3 100644 --- a/OpenSSH_GUI.Core/Lib/Keys/BasicSshKeyFileInformation.cs +++ b/OpenSSH_GUI.Core/Lib/Keys/BasicSshKeyFileInformation.cs @@ -46,8 +46,9 @@ public readonly record struct BasicSshKeyFileInformation() /// /// Extracts metadata from any supported SSH key file without requiring a passphrase. /// Supports OpenSSH public keys (.pub), OpenSSH private keys, and PuTTY keys (PPK v1/v2/v3). - /// The comment will be empty for passphrase-protected OpenSSH private keys - /// when no corresponding .pub file is present. + /// The comment will be empty when no corresponding .pub file is present, + /// because the OpenSSH private key format does not expose the key comment + /// without decrypting the private section. /// /// Descriptor of the key file(s) on disk. /// Parsed metadata, or an empty instance if the key cannot be read. @@ -64,7 +65,7 @@ public static BasicSshKeyFileInformation FromKeyFileInfo(SshKeyFileInformation k // PPK — comment lives in the unencrypted plaintext header regardless of encryption if (files.FirstOrDefault(f => - f.Extension.Equals(PathExtensions.PuttyKeyFileExtension, StringComparison.OrdinalIgnoreCase)) is + f.Extension.Replace(".", string.Empty).Equals(PathExtensions.PuttyKeyFileExtension, StringComparison.OrdinalIgnoreCase)) is { } ppkFile) return TryParseOrEmpty(() => ParsePpkFile(File.ReadAllText(ppkFile.FullName))); @@ -341,12 +342,12 @@ private static BasicSshKeyFileInformation TryParseOrEmpty(Func IsEmpty - ? hashAlgorithmName == HashAlgorithmName + ? string.Empty + : hashAlgorithmName == HashAlgorithmName ? string.Format(outputFormat, BitLength, HashAlgorithmName, FingerPrint, Comment, KeyType) : string.Format( outputFormat, BitLength, hashAlgorithmName, ComputeFingerprint([], hashAlgorithmName), - Comment, KeyType) - : string.Empty; + Comment, KeyType); /// /// Returns a human-readable string matching the output format of ssh-keygen -lf: diff --git a/OpenSSH_GUI.Core/MVVM/ViewModelBase.cs b/OpenSSH_GUI.Core/MVVM/ViewModelBase.cs index ae63173..9650142 100644 --- a/OpenSSH_GUI.Core/MVVM/ViewModelBase.cs +++ b/OpenSSH_GUI.Core/MVVM/ViewModelBase.cs @@ -34,12 +34,7 @@ public virtual ValueTask InitializeAsync( public abstract partial class ViewModelBase : ReactiveObject, IDisposable, IAsyncDisposable, IActivatableViewModel, IInitializableViewModel { - /// - /// The TopLevel this view is hosted in. Set by the view once attached to the visual tree, - /// since clipboard access must go through the actual hosting window on Windows (OLE clipboard - /// is bound to the calling HWND, not a globally cached one). - /// - public TopLevel? OwnerTopLevel { get; set; } + public Func GetTopLevel { get; set; } protected readonly CompositeDisposable Disposables; diff --git a/OpenSSH_GUI.Core/Resources/AppIconStore.cs b/OpenSSH_GUI.Core/Resources/AppIconStore.cs index b5bb543..f63fcd0 100644 --- a/OpenSSH_GUI.Core/Resources/AppIconStore.cs +++ b/OpenSSH_GUI.Core/Resources/AppIconStore.cs @@ -1,3 +1,4 @@ +using System.Collections.Frozen; using Avalonia.Controls; using Avalonia.Media.Imaging; @@ -9,18 +10,43 @@ namespace OpenSSH_GUI.Core.Resources; /// public sealed class AppIconStore { - private readonly Dictionary _bitmaps = new(); - private readonly Dictionary _windowIcons = new(); + private FrozenDictionary bitmaps = FrozenDictionary.Empty; + private FrozenDictionary windowIcons = FrozenDictionary.Empty; - /// Stores a rendered under the given key. - public void AddBitmap(string key, Bitmap bitmap) { _bitmaps[key] = bitmap; } + private Dictionary? bitmapStage = new(); + private Dictionary? windowIconStage = new(); + /// Stores a rendered under the given key. + public void AddBitmap(string key, Bitmap bitmap) + { + if (bitmapStage is null) throw new InvalidOperationException("AppIconStore is already frozen."); + bitmapStage[key] = bitmap; + } + /// Stores a under the given key. - public void AddWindowIcon(string key, WindowIcon icon) { _windowIcons[key] = icon; } + public void AddWindowIcon(string key, WindowIcon icon) + { + if (windowIconStage is null) throw new InvalidOperationException("AppIconStore is already frozen."); + windowIconStage[key] = icon; + } + + /// + /// Freezes both dictionaries + /// + public void Freeze() + { + bitmaps = bitmapStage?.ToFrozenDictionary() ?? FrozenDictionary.Empty; + windowIcons = windowIconStage?.ToFrozenDictionary() ?? FrozenDictionary.Empty; + bitmapStage?.Clear(); + windowIconStage?.Clear(); + bitmapStage = null; + windowIconStage = null; + } + /// Retrieves a by key, or if not found. - public Bitmap? GetBitmap(string key) => _bitmaps.GetValueOrDefault(key); + public Bitmap? GetBitmap(string key) => bitmaps.GetValueOrDefault(key); /// Retrieves a by key, or if not found. - public WindowIcon? GetWindowIcon(string key) => _windowIcons.GetValueOrDefault(key); + public WindowIcon? GetWindowIcon(string key) => windowIcons.GetValueOrDefault(key); } \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Resources/Wrapper/WindowBase.cs b/OpenSSH_GUI.Core/Resources/Wrapper/WindowBase.cs index 711adb7..21e8acd 100644 --- a/OpenSSH_GUI.Core/Resources/Wrapper/WindowBase.cs +++ b/OpenSSH_GUI.Core/Resources/Wrapper/WindowBase.cs @@ -46,7 +46,7 @@ protected void WindowInitialize(WindowStartupLocation startupLocation = WindowSt try { ViewModel = Services.GetRequiredKeyedService(typeof(TViewModel).Name); - ViewModel.OwnerTopLevel = GetTopLevel(this); + ViewModel.GetTopLevel = () => GetTopLevel(this); } catch (Exception e) { diff --git a/OpenSSH_GUI.Tests/App.axaml b/OpenSSH_GUI.Tests/App.axaml new file mode 100644 index 0000000..cb31e6e --- /dev/null +++ b/OpenSSH_GUI.Tests/App.axaml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/OpenSSH_GUI.Tests/App.axaml.cs b/OpenSSH_GUI.Tests/App.axaml.cs new file mode 100644 index 0000000..43b14f2 --- /dev/null +++ b/OpenSSH_GUI.Tests/App.axaml.cs @@ -0,0 +1,36 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Headless; +using Avalonia.Markup.Xaml; +using OpenSSH_GUI.Tests; +using ReactiveUI.Avalonia; + +[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))] +namespace OpenSSH_GUI.Tests; + +public partial class App : Application +{ + public override void Initialize() { AvaloniaXamlLoader.Load(this); } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new Window(); + } + + base.OnFrameworkInitializationCompleted(); + } +} + + + +public class TestAppBuilder +{ + public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() + .UseSkia() + .UsePlatformDetect() + .UseReactiveUI(builder => {}) + .UseHeadless(new AvaloniaHeadlessPlatformOptions()); +} \ No newline at end of file diff --git a/OpenSSH_GUI.Tests/Core/Extensions/PathExtensionsTests.cs b/OpenSSH_GUI.Tests/Core/Extensions/PathExtensionsTests.cs new file mode 100644 index 0000000..a5abd49 --- /dev/null +++ b/OpenSSH_GUI.Tests/Core/Extensions/PathExtensionsTests.cs @@ -0,0 +1,194 @@ +using Avalonia.Headless.XUnit; +using OpenSSH_GUI.Core.Extensions; +using Shouldly; +using Xunit; + +namespace OpenSSH_GUI.Tests.Core.Extensions; + +public class PathExtensionsTests +{ + [AvaloniaFact] + public void WithJsonExtension_AppendsJson() { Path.WithJsonExtension("config").ShouldBe("config.json"); } + + [AvaloniaFact] + public void WithLogExtension_AppendsLog() { Path.WithLogExtension("app").ShouldBe("app.log"); } + + [AvaloniaFact] + public void WithOpenSshPublicKeyExtension_AppendsPub() { Path.WithOpenSshPublicKeyExtension("id_rsa").ShouldBe("id_rsa.pub"); } + + [AvaloniaFact] + public void WithPuTTYKeyExtension_AppendsPpk() { Path.WithPuTTYKeyExtension("id_rsa").ShouldBe("id_rsa.ppk"); } + + [AvaloniaTheory] + [InlineData("file.json", true)] + [InlineData("file.JSON", true)] + [InlineData("file.txt", false)] + public void IsJson_Tests(string path, bool expected) { Path.IsJson(path).ShouldBe(expected); } + + [AvaloniaTheory] + [InlineData("file.log", true)] + [InlineData("file.LOG", true)] + [InlineData("file.txt", false)] + public void IsLog_Tests(string path, bool expected) { Path.IsLog(path).ShouldBe(expected); } + + [AvaloniaTheory] + [InlineData("key.ppk", true)] + [InlineData("key.PPK", true)] + [InlineData("key.pub", false)] + public void IsPuTTYKey_Tests(string path, bool expected) { Path.IsPuTTYKey(path).ShouldBe(expected); } + + [AvaloniaTheory] + [InlineData("id_rsa.pub", true)] + [InlineData("id_rsa.PUB", true)] + [InlineData("id_rsa", false)] + public void IsOpenSshPublicKey_Tests(string path, bool expected) { Path.IsOpenSshPublicKey(path).ShouldBe(expected); } + + [AvaloniaTheory] + [InlineData("file.txt", "txt", true)] + [InlineData("file.TXT", "txt", true)] + [InlineData("file.txt", "json", false)] + public void HasExtension_Tests(string path, string extension, bool expected) { Path.HasExtension(path, extension).ShouldBe(expected); } + + [AvaloniaTheory] + [InlineData("file.TXT", ".txt")] + [InlineData("file", "")] + [InlineData("archive.tar.gz", ".gz")] + public void GetNormalizedExtension_Tests(string path, string expected) { Path.GetNormalizedExtension(path).ShouldBe(expected); } + + [AvaloniaFact] + public void Directory_CreateIfNotExists_CreatesMissingDirectory() + { + var dir = System.IO.Directory.CreateTempSubdirectory(); + try + { + var target = System.IO.Path.Combine(dir.FullName, "nested", "dir"); + + Directory.CreateIfNotExists(target); + + System.IO.Directory.Exists(target).ShouldBeTrue(); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void Directory_CreateIfNotExists_ExistingDirectory_DoesNotThrow() + { + var dir = System.IO.Directory.CreateTempSubdirectory(); + try + { + Should.NotThrow(() => Directory.CreateIfNotExists(dir.FullName)); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void Directory_CreateIfNotExists_NullOrWhitespace_DoesNothing() + { + Should.NotThrow(() => Directory.CreateIfNotExists(null)); + Should.NotThrow(() => Directory.CreateIfNotExists(" ")); + } + + [AvaloniaFact] + public void File_CreateIfNotExists_CreatesFileWithContent() + { + var dir = System.IO.Directory.CreateTempSubdirectory(); + try + { + var target = System.IO.Path.Combine(dir.FullName, "sub", "file.txt"); + + File.CreateIfNotExists(target, "hello world"); + + System.IO.File.Exists(target).ShouldBeTrue(); + System.IO.File.ReadAllText(target).ShouldBe("hello world"); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void File_CreateIfNotExists_CreatesEmptyFileWithoutContent() + { + var dir = System.IO.Directory.CreateTempSubdirectory(); + try + { + var target = System.IO.Path.Combine(dir.FullName, "file.txt"); + + File.CreateIfNotExists(target); + + System.IO.File.Exists(target).ShouldBeTrue(); + System.IO.File.ReadAllText(target).ShouldBeEmpty(); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void File_CreateIfNotExists_ExistingFile_DoesNotOverwrite() + { + var dir = System.IO.Directory.CreateTempSubdirectory(); + try + { + var target = System.IO.Path.Combine(dir.FullName, "file.txt"); + System.IO.File.WriteAllText(target, "original"); + + File.CreateIfNotExists(target, "new content"); + + System.IO.File.ReadAllText(target).ShouldBe("original"); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void File_CreateIfNotExists_NullPath_Throws() + { + Should.Throw(() => File.CreateIfNotExists(null!)); + } + + [AvaloniaFact] + public void File_RemoveIfExists_DeletesExistingFile() + { + var dir = System.IO.Directory.CreateTempSubdirectory(); + try + { + var target = System.IO.Path.Combine(dir.FullName, "file.txt"); + System.IO.File.WriteAllText(target, "content"); + + File.RemoveIfExists(target); + + System.IO.File.Exists(target).ShouldBeFalse(); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void File_RemoveIfExists_NonExistentFile_DoesNotThrow() + { + var dir = System.IO.Directory.CreateTempSubdirectory(); + try + { + var target = System.IO.Path.Combine(dir.FullName, "does-not-exist.txt"); + + Should.NotThrow(() => File.RemoveIfExists(target)); + } + finally + { + dir.Delete(true); + } + } +} diff --git a/OpenSSH_GUI.Tests/Core/Extensions/PlatformIdExtensionsTests.cs b/OpenSSH_GUI.Tests/Core/Extensions/PlatformIdExtensionsTests.cs new file mode 100644 index 0000000..2429712 --- /dev/null +++ b/OpenSSH_GUI.Tests/Core/Extensions/PlatformIdExtensionsTests.cs @@ -0,0 +1,19 @@ +using Avalonia.Headless.XUnit; +using OpenSSH_GUI.Core.Extensions; +using Shouldly; +using Xunit; + +namespace OpenSSH_GUI.Tests.Core.Extensions; + +public class PlatformIdExtensionsTests +{ + [AvaloniaTheory] + [InlineData(PlatformID.Win32NT, "\r\n")] + [InlineData(PlatformID.Win32Windows, "\r\n")] + [InlineData(PlatformID.Win32S, "\r\n")] + [InlineData(PlatformID.WinCE, "\r\n")] + [InlineData(PlatformID.Unix, "\n")] + [InlineData(PlatformID.MacOSX, "\n")] + [InlineData(PlatformID.Other, "\n")] + public void GetLineSeparator_Tests(PlatformID platformId, string expected) { platformId.GetLineSeparator().ShouldBe(expected); } +} diff --git a/OpenSSH_GUI.Tests/Core/Extensions/ServiceCollectionExtensionsTests.cs b/OpenSSH_GUI.Tests/Core/Extensions/ServiceCollectionExtensionsTests.cs index 9afac9c..0f07ab5 100644 --- a/OpenSSH_GUI.Tests/Core/Extensions/ServiceCollectionExtensionsTests.cs +++ b/OpenSSH_GUI.Tests/Core/Extensions/ServiceCollectionExtensionsTests.cs @@ -1,4 +1,5 @@ using Avalonia.Controls; +using Avalonia.Headless.XUnit; using Avalonia.Threading; using Microsoft.Extensions.DependencyInjection; using OpenSSH_GUI.Core.Extensions; @@ -9,7 +10,7 @@ namespace OpenSSH_GUI.Tests.Core.Extensions; public class DependencyInjectionExtensionsTests { - [Fact] + [AvaloniaFact] public void RegisterViewWithViewModel_ValidNaming_ShouldRegister() { // Arrange @@ -28,7 +29,7 @@ public void RegisterViewWithViewModel_ValidNaming_ShouldRegister() }); } - [Fact] + [AvaloniaFact] public void RegisterViewWithViewModel_InvalidNaming_ShouldThrow() { // Arrange diff --git a/OpenSSH_GUI.Tests/Core/Extensions/SshConfigExtensionsTests.cs b/OpenSSH_GUI.Tests/Core/Extensions/SshConfigExtensionsTests.cs new file mode 100644 index 0000000..47aed9e --- /dev/null +++ b/OpenSSH_GUI.Tests/Core/Extensions/SshConfigExtensionsTests.cs @@ -0,0 +1,224 @@ +using Avalonia.Headless.XUnit; +using OpenSSH_GUI.Core.Extensions; +using OpenSSH_GUI.Core.Lib.Misc; +using OpenSSH_GUI.SshConfig.Parsers; +using Shouldly; +using Xunit; + +namespace OpenSSH_GUI.Tests.Core.Extensions; + +public class SshConfigExtensionsTests +{ + [AvaloniaFact] + public void GetConnectionEntriesFromConfig_HostWithIdentityFile_ReturnsKeyConnectionCredentials() + { + const string config = """ + Host myserver + HostName example.com + User bob + IdentityFile ~/.ssh/id_rsa + """; + var document = SshConfigParser.Parse(config); + + var result = document.GetConnectionEntriesFromConfig().ToArray(); + + result.Length.ShouldBe(1); + var credentials = result[0].ShouldBeOfType(); + credentials.Hostname.ShouldBe("example.com"); + credentials.Username.ShouldBe("bob"); + credentials.Port.ShouldBe(22); + } + + [AvaloniaFact] + public void GetConnectionEntriesFromConfig_HostWithoutIdentityFile_ReturnsPasswordConnectionCredentials() + { + const string config = """ + Host myserver + HostName example.com + User bob + """; + var document = SshConfigParser.Parse(config); + + var result = document.GetConnectionEntriesFromConfig().ToArray(); + + result.Length.ShouldBe(1); + result[0].ShouldBeOfType(); + } + + [AvaloniaFact] + public void GetConnectionEntriesFromConfig_NoHostName_FallsBackToPattern() + { + const string config = """ + Host myserver + User bob + """; + var document = SshConfigParser.Parse(config); + + var result = document.GetConnectionEntriesFromConfig().ToArray(); + + result.Length.ShouldBe(1); + result[0].Hostname.ShouldBe("myserver"); + } + + [AvaloniaFact] + public void GetConnectionEntriesFromConfig_NoUser_FallsBackToGlobalUser() + { + const string config = """ + User globaluser + + Host myserver + HostName example.com + """; + var document = SshConfigParser.Parse(config); + + var result = document.GetConnectionEntriesFromConfig().ToArray(); + + result.Length.ShouldBe(1); + result[0].Username.ShouldBe("globaluser"); + } + + [AvaloniaFact] + public void GetConnectionEntriesFromConfig_NoUserAtAll_FallsBackToEnvironmentUserName() + { + const string config = """ + Host myserver + HostName example.com + """; + var document = SshConfigParser.Parse(config); + + var result = document.GetConnectionEntriesFromConfig().ToArray(); + + result.Length.ShouldBe(1); + result[0].Username.ShouldBe(Environment.UserName); + } + + [AvaloniaFact] + public void GetConnectionEntriesFromConfig_NonDefaultPort_IsUsed() + { + const string config = """ + Host myserver + HostName example.com + User bob + Port 2222 + """; + var document = SshConfigParser.Parse(config); + + var result = document.GetConnectionEntriesFromConfig().ToArray(); + + result.Length.ShouldBe(1); + result[0].Hostname.ShouldBe("example.com"); + result[0].Port.ShouldBe(2222); + } + + [AvaloniaFact] + public void GetConnectionEntriesFromConfig_DefaultPort22_IsNotAppended() + { + const string config = """ + Host myserver + HostName example.com + User bob + Port 22 + """; + var document = SshConfigParser.Parse(config); + + var result = document.GetConnectionEntriesFromConfig().ToArray(); + + result[0].Hostname.ShouldBe("example.com"); + } + + [AvaloniaFact] + public void GetConnectionEntriesFromConfig_GlobalPort_UsedWhenHostHasNone() + { + const string config = """ + Port 2200 + + Host myserver + HostName example.com + User bob + """; + var document = SshConfigParser.Parse(config); + + var result = document.GetConnectionEntriesFromConfig().ToArray(); + + result[0].Hostname.ShouldBe("example.com"); + result[0].Port.ShouldBe(2200); + result[0].Username.ShouldBe("bob"); + } + + [AvaloniaFact] + public void GetConnectionEntriesFromConfig_GlobalIdentityFile_UsedWhenHostHasNone() + { + const string config = """ + IdentityFile ~/.ssh/global_key + + Host myserver + HostName example.com + User bob + """; + var document = SshConfigParser.Parse(config); + + var result = document.GetConnectionEntriesFromConfig().ToArray(); + + result[0].ShouldBeOfType(); + } + + [AvaloniaTheory] + [InlineData("Host *\n User bob")] + [InlineData("Host ?erver\n User bob")] + [InlineData("Host !excluded\n User bob")] + public void GetConnectionEntriesFromConfig_WildcardOrNegatedPatterns_AreSkipped(string config) + { + var document = SshConfigParser.Parse(config); + + var result = document.GetConnectionEntriesFromConfig().ToArray(); + + result.ShouldBeEmpty(); + } + + [AvaloniaFact] + public void GetConnectionEntriesFromConfig_MultiplePatternsOnOneHostLine_YieldsOneEntryPerPattern() + { + const string config = """ + Host server1 server2 + User bob + """; + var document = SshConfigParser.Parse(config); + + var result = document.GetConnectionEntriesFromConfig().ToArray(); + + result.Length.ShouldBe(2); + result.Select(r => r.Hostname).ShouldBe(["server1", "server2"], ignoreOrder: true); + } + + [AvaloniaFact] + public void GetConnectionEntriesFromConfig_EmptyDocument_ReturnsEmpty() + { + var document = SshConfigParser.Parse(string.Empty); + + var result = document.GetConnectionEntriesFromConfig().ToArray(); + + result.ShouldBeEmpty(); + } + + [AvaloniaFact] + public void GetConnectionEntriesFromConfig_MultipleHosts_ReturnsEntryPerHost() + { + const string config = """ + Host alpha + HostName alpha.example.com + User alice + + Host beta + HostName beta.example.com + User bob + IdentityFile ~/.ssh/beta_key + """; + var document = SshConfigParser.Parse(config); + + var result = document.GetConnectionEntriesFromConfig().ToArray(); + + result.Length.ShouldBe(2); + result.ShouldContain(r => r.Hostname == "alpha.example.com" && r is PasswordConnectionCredentials); + result.ShouldContain(r => r.Hostname == "beta.example.com" && r is KeyConnectionCredentials); + } +} diff --git a/OpenSSH_GUI.Tests/Core/Extensions/SshConfigFilesExtensionTests.cs b/OpenSSH_GUI.Tests/Core/Extensions/SshConfigFilesExtensionTests.cs index f0d8f8b..3833777 100644 --- a/OpenSSH_GUI.Tests/Core/Extensions/SshConfigFilesExtensionTests.cs +++ b/OpenSSH_GUI.Tests/Core/Extensions/SshConfigFilesExtensionTests.cs @@ -1,3 +1,4 @@ +using Avalonia.Headless.XUnit; using OpenSSH_GUI.Core.Enums; using OpenSSH_GUI.Core.Extensions; using Shouldly; @@ -7,13 +8,13 @@ namespace OpenSSH_GUI.Tests.Core.Extensions; public class SshConfigFilesExtensionTests { - [Theory, InlineData(PlatformID.Win32NT, false, "%PROGRAMDATA%\\ssh"), InlineData(PlatformID.Unix, false, "/etc/ssh")] + [AvaloniaTheory, InlineData(PlatformID.Win32NT, false, "%PROGRAMDATA%\\ssh"), InlineData(PlatformID.Unix, false, "/etc/ssh")] public void GetRootSshPath_Tests(PlatformID platform, bool resolve, string expected) { SshConfigFilesExtension.GetRootSshPath(resolve, platform).ShouldBe(expected); } - [Theory, InlineData(PlatformID.Win32NT, false, "%USERPROFILE%\\.ssh"), InlineData(PlatformID.Unix, false, "%HOME%/.ssh")] + [AvaloniaTheory, InlineData(PlatformID.Win32NT, false, "%USERPROFILE%\\.ssh"), InlineData(PlatformID.Unix, false, "%HOME%/.ssh")] public void GetBaseSshPath_Tests(PlatformID platform, bool resolve, string expected) { SshConfigFilesExtension.GetBaseSshPath(resolve, platform).ShouldBe(expected); } - [Theory, InlineData(SshConfigFiles.Config, PlatformID.Win32NT, false, "%USERPROFILE%\\.ssh\\config"), + [AvaloniaTheory, InlineData(SshConfigFiles.Config, PlatformID.Win32NT, false, "%USERPROFILE%\\.ssh\\config"), InlineData(SshConfigFiles.Config, PlatformID.Unix, false, "%HOME%/.ssh/config"), InlineData(SshConfigFiles.Sshd_Config, PlatformID.Unix, false, "/etc/ssh/sshd_config")] public void GetPathOfFile_Tests(SshConfigFiles file, PlatformID platform, bool resolve, string expected) { file.GetPathOfFile(resolve, platform).ShouldBe(expected); } } \ No newline at end of file diff --git a/OpenSSH_GUI.Tests/Core/Extensions/SshKeyFormatExtensionTests.cs b/OpenSSH_GUI.Tests/Core/Extensions/SshKeyFormatExtensionTests.cs index 8960a62..021c314 100644 --- a/OpenSSH_GUI.Tests/Core/Extensions/SshKeyFormatExtensionTests.cs +++ b/OpenSSH_GUI.Tests/Core/Extensions/SshKeyFormatExtensionTests.cs @@ -1,3 +1,4 @@ +using Avalonia.Headless.XUnit; using OpenSSH_GUI.Core.Extensions; using Shouldly; using SshNet.Keygen; @@ -7,10 +8,10 @@ namespace OpenSSH_GUI.Tests.Core.Extensions; public class SshKeyFormatExtensionTests { - [Theory, InlineData(SshKeyFormat.OpenSSH, true, "pub"), InlineData(SshKeyFormat.OpenSSH, false, null), InlineData(SshKeyFormat.PuTTYv2, false, "ppk"), + [AvaloniaTheory, InlineData(SshKeyFormat.OpenSSH, true, "pub"), InlineData(SshKeyFormat.OpenSSH, false, null), InlineData(SshKeyFormat.PuTTYv2, false, "ppk"), InlineData(SshKeyFormat.PuTTYv3, true, "ppk")] public void GetExtension_Tests(SshKeyFormat format, bool isPublic, string? expected) { format.GetExtension(isPublic).ShouldBe(expected); } - [Theory, InlineData(SshKeyFormat.OpenSSH, "test.key", true, "test.pub"), InlineData(SshKeyFormat.PuTTYv3, "test.key", false, "test.ppk")] + [AvaloniaTheory, InlineData(SshKeyFormat.OpenSSH, "test.key", true, "test.pub"), InlineData(SshKeyFormat.PuTTYv3, "test.key", false, "test.ppk")] public void ChangeExtension_Tests(SshKeyFormat format, string path, bool isPublic, string expected) { format.ChangeExtension(path, isPublic).ShouldBe(expected); } } \ No newline at end of file diff --git a/OpenSSH_GUI.Tests/Core/Extensions/SshKeyTypeExtensionTests.cs b/OpenSSH_GUI.Tests/Core/Extensions/SshKeyTypeExtensionTests.cs index 2e762d7..69cfcdb 100644 --- a/OpenSSH_GUI.Tests/Core/Extensions/SshKeyTypeExtensionTests.cs +++ b/OpenSSH_GUI.Tests/Core/Extensions/SshKeyTypeExtensionTests.cs @@ -1,3 +1,4 @@ +using Avalonia.Headless.XUnit; using OpenSSH_GUI.Core.Extensions; using SshNet.Keygen; using Xunit; @@ -6,7 +7,7 @@ namespace OpenSSH_GUI.Tests.Core.Extensions; public class SshKeyTypeExtensionTests { - [Theory, InlineData(SshKeyType.RSA), InlineData(SshKeyType.ECDSA), InlineData(SshKeyType.ED25519)] + [AvaloniaTheory, InlineData(SshKeyType.RSA), InlineData(SshKeyType.ECDSA), InlineData(SshKeyType.ED25519)] public static void SshKeyType_Tests(SshKeyType sshKeyType) { var bitValues = sshKeyType.SupportedKeySizes; diff --git a/OpenSSH_GUI.Tests/Core/Extensions/StringExtensionsTests.cs b/OpenSSH_GUI.Tests/Core/Extensions/StringExtensionsTests.cs index a99e794..16309f3 100644 --- a/OpenSSH_GUI.Tests/Core/Extensions/StringExtensionsTests.cs +++ b/OpenSSH_GUI.Tests/Core/Extensions/StringExtensionsTests.cs @@ -1,3 +1,4 @@ +using Avalonia.Headless.XUnit; using OpenSSH_GUI.Core.Extensions; using Shouldly; using Xunit; @@ -6,42 +7,42 @@ namespace OpenSSH_GUI.Tests.Core.Extensions; public class StringExtensionsTests { - [Theory, InlineData("HelloWorld", "hello_world")] + [AvaloniaTheory, InlineData("HelloWorld", "hello_world")] public void ToSnakeCase_Tests(string input, string expected) { input.ToSnakeCase().ShouldBe(expected); } - [Theory, InlineData("HelloWorld", "helloWorld")] + [AvaloniaTheory, InlineData("HelloWorld", "helloWorld")] public void ToCamelCase_Tests(string input, string expected) { input.ToCamelCase().ShouldBe(expected); } - [Theory, InlineData("HelloWorld", "hello-world")] + [AvaloniaTheory, InlineData("HelloWorld", "hello-world")] public void ToKebabCase_Tests(string input, string expected) { input.ToKebabCase().ShouldBe(expected); } - [Theory, InlineData("hello world", "HelloWorld")] + [AvaloniaTheory, InlineData("hello world", "HelloWorld")] public void ToPascalCase_Tests(string input, string expected) { input.ToPascalCase().ShouldBe(expected); } - [Fact] + [AvaloniaFact] public void SplitToChunks_Tests() { "abcdef".SplitToChunks(2).ShouldBe(["ab", "cd", "ef"]); "abcde".SplitToChunks(2).ShouldBe(["ab", "cd", "e"]); } - [Fact] + [AvaloniaFact] public void Wrap_Tests() { var input = "abcdef"; input.Wrap(2, "|").ShouldBe("ab|cd|ef"); } - [Fact] + [AvaloniaFact] public void ToTitleCase_Tests() { "this is a title".ToTitleCase().ShouldBe("This Is A Title"); } - [Fact] + [AvaloniaFact] public void ToSentenceCase_Tests() { "THIS IS A SENTENCE.".ToSentenceCase().ShouldBe("This is a sentence."); } - [Fact] + [AvaloniaFact] public void ToLeetSpeak_Tests() { "leetspeak".ToLeetSpeak().ShouldBe("l33t5p34k"); } - [Fact] + [AvaloniaFact] public void ToStudlyCaps_Tests() { // Random, so we just check it doesn't throw and length is same diff --git a/OpenSSH_GUI.Tests/Core/Lib/Keys/BasicSshKeyFileInformationTests.cs b/OpenSSH_GUI.Tests/Core/Lib/Keys/BasicSshKeyFileInformationTests.cs new file mode 100644 index 0000000..4a8fb01 --- /dev/null +++ b/OpenSSH_GUI.Tests/Core/Lib/Keys/BasicSshKeyFileInformationTests.cs @@ -0,0 +1,311 @@ +using Avalonia.Headless.XUnit; +using OpenSSH_GUI.Core.Lib.Keys; +using Shouldly; +using SshNet.Keygen; +using SshNet.Keygen.Extensions; +using SshNet.Keygen.SshKeyEncryption; +using Xunit; + +namespace OpenSSH_GUI.Tests.Core.Lib.Keys; + +public class BasicSshKeyFileInformationTests +{ + private static (string PrivatePath, string PublicPath) GenerateOpenSshKeyPair(string dir, SshKeyType type = SshKeyType.ED25519, string comment = "test-comment", + string? passphrase = null, string name = "id_test", int? length = null) + { + var privatePath = Path.Combine(dir, name); + var info = new SshKeyGenerateInfo(type) { Comment = comment }; + if (length is { } l) info.KeyLength = l; + if (passphrase is not null) info.Encryption = new SshKeyEncryptionAes256(passphrase); + var generated = SshKey.Generate(privatePath, FileMode.Create, info); + var publicPath = privatePath + ".pub"; + File.WriteAllText(publicPath, generated.ToOpenSshPublicFormat()); + return (privatePath, publicPath); + } + + private static string GeneratePpkKey(string dir, SshKeyType type = SshKeyType.RSA, SshKeyFormat format = SshKeyFormat.PuTTYv3, string comment = "ppk-comment", + string name = "id_test.ppk") + { + var path = Path.Combine(dir, name); + var info = new SshKeyGenerateInfo(type) { Comment = comment, KeyFormat = format }; + SshKey.Generate(path, FileMode.Create, info); + return path; + } + + [AvaloniaFact] + public void FromKeyFileInfo_NonExistentFile_ReturnsEmpty() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(Path.Combine(dir.FullName, "does_not_exist"))); + var result = BasicSshKeyFileInformation.FromKeyFileInfo(info); + + result.FingerPrint.ShouldBeEmpty(); + result.Comment.ShouldBeEmpty(); + result.BitLength.ShouldBe(0); + result.KeyType.ShouldBe(SshKeyType.RSA); + result.Format.ShouldBe(SshKeyFormat.OpenSSH); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void FromKeyFileInfo_OpenSshEd25519WithPubFile_ParsesCorrectly() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var (privatePath, _) = GenerateOpenSshKeyPair(dir.FullName, SshKeyType.ED25519, "alice@host"); + + var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(privatePath)); + var result = BasicSshKeyFileInformation.FromKeyFileInfo(info); + + result.Comment.ShouldBe("alice@host"); + result.KeyType.ShouldBe(SshKeyType.ED25519); + result.BitLength.ShouldBe(256); + result.Format.ShouldBe(SshKeyFormat.OpenSSH); + result.HashAlgorithmName.ShouldBe(SshKeyHashAlgorithmName.SHA256); + result.FingerPrint.ShouldNotBeNullOrEmpty(); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void FromKeyFileInfo_OpenSshRsaWithPubFile_ParsesCorrectBitLength() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var (privatePath, _) = GenerateOpenSshKeyPair(dir.FullName, SshKeyType.RSA, "rsa-comment", length: 2048); + + var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(privatePath)); + var result = BasicSshKeyFileInformation.FromKeyFileInfo(info); + + result.KeyType.ShouldBe(SshKeyType.RSA); + result.BitLength.ShouldBe(2048); + result.Comment.ShouldBe("rsa-comment"); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaTheory] + [InlineData(256)] + [InlineData(384)] + [InlineData(521)] + public void FromKeyFileInfo_OpenSshEcdsaWithPubFile_ParsesCorrectBitLength(int length) + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var (privatePath, _) = GenerateOpenSshKeyPair(dir.FullName, SshKeyType.ECDSA, "ecdsa-comment", length: length); + + var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(privatePath)); + var result = BasicSshKeyFileInformation.FromKeyFileInfo(info); + + result.KeyType.ShouldBe(SshKeyType.ECDSA); + result.BitLength.ShouldBe(length); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void FromKeyFileInfo_EmptyCommentInPubFile_CommentIsEmpty() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var (privatePath, _) = GenerateOpenSshKeyPair(dir.FullName, SshKeyType.ED25519, string.Empty); + + var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(privatePath)); + var result = BasicSshKeyFileInformation.FromKeyFileInfo(info); + + result.Comment.ShouldBeEmpty(); + result.FingerPrint.ShouldNotBeNullOrEmpty(); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void FromKeyFileInfo_MalformedPubFileContent_ReturnsEmpty() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var privatePath = Path.Combine(dir.FullName, "id_bad"); + File.WriteAllText(privatePath, "not a real key"); + File.WriteAllText(privatePath + ".pub", "onlyoneword"); + + var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(privatePath)); + var result = BasicSshKeyFileInformation.FromKeyFileInfo(info); + + result.FingerPrint.ShouldBeEmpty(); + result.Comment.ShouldBeEmpty(); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void FromKeyFileInfo_PubFileWithInvalidBase64_ReturnsEmpty() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var privatePath = Path.Combine(dir.FullName, "id_bad2"); + File.WriteAllText(privatePath, "not a real key"); + File.WriteAllText(privatePath + ".pub", "ssh-ed25519 not-valid-base64!!! comment"); + + var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(privatePath)); + var result = BasicSshKeyFileInformation.FromKeyFileInfo(info); + + result.FingerPrint.ShouldBeEmpty(); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void FromKeyFileInfo_PuttyV3Key_ParsesCorrectly() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var path = GeneratePpkKey(dir.FullName, SshKeyType.RSA, SshKeyFormat.PuTTYv3, "putty-comment"); + + var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(path)); + var result = BasicSshKeyFileInformation.FromKeyFileInfo(info); + + result.Format.ShouldBe(SshKeyFormat.PuTTYv3); + result.KeyType.ShouldBe(SshKeyType.RSA); + result.Comment.ShouldBe("putty-comment"); + result.FingerPrint.ShouldNotBeNullOrEmpty(); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void FromKeyFileInfo_PuttyV2Key_FormatIsPuTTYv2() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var path = GeneratePpkKey(dir.FullName, SshKeyType.ED25519, SshKeyFormat.PuTTYv2, "putty2-comment"); + + var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(path)); + var result = BasicSshKeyFileInformation.FromKeyFileInfo(info); + + result.Format.ShouldBe(SshKeyFormat.PuTTYv2); + result.KeyType.ShouldBe(SshKeyType.ED25519); + result.Comment.ShouldBe("putty2-comment"); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void FromKeyFileInfo_EncryptedPuttyKey_StillReadsCommentAndFingerprintFromPlaintextHeader() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var path = Path.Combine(dir.FullName, "id_enc.ppk"); + var genInfo = new SshKeyGenerateInfo(SshKeyType.ED25519) + { + Comment = "encrypted-comment", + KeyFormat = SshKeyFormat.PuTTYv3, + Encryption = new SshKeyEncryptionAes256("s3cr3t", new PuttyV3Encryption()) + }; + SshKey.Generate(path, FileMode.Create, genInfo); + + var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(path)); + var result = BasicSshKeyFileInformation.FromKeyFileInfo(info); + + result.Comment.ShouldBe("encrypted-comment"); + result.FingerPrint.ShouldNotBeNullOrEmpty(); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void FromKeyFileInfo_OpenSshPrivateKeyWithoutPubFile_ReturnsEmpty_DueToUnconditionalPubFileLookup() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var (privatePath, publicPath) = GenerateOpenSshKeyPair(dir.FullName, SshKeyType.ED25519, "bob@host"); + File.Delete(publicPath); + + var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(privatePath)); + var result = BasicSshKeyFileInformation.FromKeyFileInfo(info); + + result.FingerPrint.ShouldBeEmpty(); + result.Comment.ShouldBeEmpty(); + result.BitLength.ShouldBe(0); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void ToString_ForSuccessfullyParsedKey_ReturnsPopulatedString() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var (privatePath, _) = GenerateOpenSshKeyPair(dir.FullName, SshKeyType.ED25519, "carol@host"); + var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(privatePath)); + var result = BasicSshKeyFileInformation.FromKeyFileInfo(info); + + result.FingerPrint.ShouldNotBeNullOrEmpty(); + result.ToString().ShouldNotBe(string.Empty); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void ToString_ForEmptyInstance_ReturnsEmptyString() + => new BasicSshKeyFileInformation().ToString().ShouldBeNullOrWhiteSpace(); + + [AvaloniaFact] + public void Equals_TwoDefaultInstances_AreEqual() + { + var a = new BasicSshKeyFileInformation(); + var b = new BasicSshKeyFileInformation(); + + a.ShouldBe(b); + a.GetHashCode().ShouldBe(b.GetHashCode()); + } +} diff --git a/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFactoryTests.cs b/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFactoryTests.cs new file mode 100644 index 0000000..d318e43 --- /dev/null +++ b/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFactoryTests.cs @@ -0,0 +1,54 @@ +using Avalonia.Headless.XUnit; +using Microsoft.Extensions.Logging; +using NSubstitute; +using OpenSSH_GUI.Core.Lib.Keys; +using Shouldly; +using Xunit; + +namespace OpenSSH_GUI.Tests.Core.Lib.Keys; + +public class SshKeyFactoryTests +{ + [AvaloniaFact] + public void Create_ReturnsNewSshKeyFileInstance() + { + var logger = Substitute.For>(); + var loggerFactory = Substitute.For(); + loggerFactory.CreateLogger(Arg.Any()).Returns(Substitute.For()); + + var factory = new SshKeyFactory(logger, loggerFactory); + + var result = factory.Create(); + + result.ShouldNotBeNull(); + result.ShouldBeOfType(); + } + + [AvaloniaFact] + public void Create_UsesLoggerFactoryToCreateSshKeyFileLogger() + { + var logger = Substitute.For>(); + var loggerFactory = Substitute.For(); + loggerFactory.CreateLogger(Arg.Any()).Returns(Substitute.For()); + + var factory = new SshKeyFactory(logger, loggerFactory); + factory.Create(); + + loggerFactory.Received(1).CreateLogger(typeof(SshKeyFile).FullName!); + } + + [AvaloniaFact] + public void Create_CalledMultipleTimes_ReturnsDistinctInstances() + { + var logger = Substitute.For>(); + var loggerFactory = Substitute.For(); + loggerFactory.CreateLogger(Arg.Any()).Returns(Substitute.For()); + + var factory = new SshKeyFactory(logger, loggerFactory); + + var first = factory.Create(); + var second = factory.Create(); + + first.ShouldNotBeSameAs(second); + } +} diff --git a/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFileInformationTests.cs b/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFileInformationTests.cs new file mode 100644 index 0000000..45fcf1a --- /dev/null +++ b/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFileInformationTests.cs @@ -0,0 +1,160 @@ +using Avalonia.Headless.XUnit; +using OpenSSH_GUI.Core.Lib.Keys; +using Shouldly; +using SshNet.Keygen; +using Xunit; + +namespace OpenSSH_GUI.Tests.Core.Lib.Keys; + +public class SshKeyFileInformationTests +{ + [AvaloniaFact] + public void Constructor_ExistingOpenSshFile_ComputesMetadataCorrectly() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var path = Path.Combine(dir.FullName, "id_rsa"); + File.WriteAllText(path, "dummy"); + + var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(path)); + + info.Exists.ShouldBeTrue(); + info.FileName.ShouldBe("id_rsa"); + info.FullFileName.ShouldBe(path); + info.DirectoryName.ShouldBe(dir.FullName); + info.CurrentFormat.ShouldBe(SshKeyFormat.OpenSSH); + info.IsOpenSshKey.ShouldBeTrue(); + info.PublicKeyFileName.ShouldBe(path + ".pub"); + info.CanChangeFileName.ShouldBeTrue(); + info.Files.Length.ShouldBe(2); + info.AvailableFormatsForConversion.ShouldNotContain(SshKeyFormat.OpenSSH); + info.DefaultConversionFormat.ShouldNotBe(SshKeyFormat.OpenSSH); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void Constructor_NonExistentFile_ExistsIsFalse() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var path = Path.Combine(dir.FullName, "missing_key"); + var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(path)); + + info.Exists.ShouldBeFalse(); + info.FileName.ShouldBe("missing_key"); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void Constructor_PpkFile_HasNoPublicKeyFileName() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var path = Path.Combine(dir.FullName, "id.ppk"); + File.WriteAllText(path, "dummy"); + + var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(path)); + + info.CurrentFormat.ShouldBe(SshKeyFormat.PuTTYv3); + info.IsOpenSshKey.ShouldBeFalse(); + info.PublicKeyFileName.ShouldBeNull(); + info.Files.Length.ShouldBe(1); + info.AvailableFormatsForConversion.ShouldContain(SshKeyFormat.OpenSSH); + info.DefaultConversionFormat.ShouldBe(SshKeyFormat.OpenSSH); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void Constructor_ProvidedByConfigSource_CanChangeFileNameIsFalse() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var path = Path.Combine(dir.FullName, "id_cfg"); + File.WriteAllText(path, "dummy"); + + var info = new SshKeyFileInformation(SshKeyFileSource.FromConfig(path)); + + info.CanChangeFileName.ShouldBeFalse(); + info.KeyFileSource.ProvidedByConfig.ShouldBeTrue(); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void Constructor_EmptyPath_FallsBackToAppContextBaseDirectory() + { + var info = new SshKeyFileInformation(new SshKeyFileSource()); + + info.FullFileName.ShouldBe(new FileInfo(AppContext.BaseDirectory).FullName); + } + + [AvaloniaFact] + public void Equals_SameKeyFileSource_AreEqual() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var path = Path.Combine(dir.FullName, "id_x"); + File.WriteAllText(path, "dummy"); + + var a = new SshKeyFileInformation(SshKeyFileSource.FromDisk(path)); + var b = new SshKeyFileInformation(SshKeyFileSource.FromDisk(path)); + + a.ShouldBe(b); + a.GetHashCode().ShouldBe(b.GetHashCode()); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void Equals_DifferentKeyFileSource_AreNotEqual() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var pathA = Path.Combine(dir.FullName, "id_a"); + var pathB = Path.Combine(dir.FullName, "id_b"); + File.WriteAllText(pathA, "dummy"); + File.WriteAllText(pathB, "dummy"); + + var a = new SshKeyFileInformation(SshKeyFileSource.FromDisk(pathA)); + var b = new SshKeyFileInformation(SshKeyFileSource.FromDisk(pathB)); + + a.ShouldNotBe(b); + } + finally + { + dir.Delete(true); + } + } + + [AvaloniaFact] + public void Equals_Null_ReturnsFalse() + { + var info = new SshKeyFileInformation(new SshKeyFileSource()); + + info.Equals(null).ShouldBeFalse(); + } +} diff --git a/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFilePasswordTests.cs b/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFilePasswordTests.cs new file mode 100644 index 0000000..3316ea2 --- /dev/null +++ b/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFilePasswordTests.cs @@ -0,0 +1,130 @@ +using System.Text; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using OpenSSH_GUI.Core.Lib.Keys; +using Shouldly; +using SshNet.Keygen.SshKeyEncryption; +using Xunit; + +namespace OpenSSH_GUI.Tests.Core.Lib.Keys; + +public class SshKeyFilePasswordTests +{ + [AvaloniaFact] + public void Default_IsNotValid_AndSpanIsEmpty() + { + using var password = new SshKeyFilePassword(); + + password.IsValid.ShouldBeFalse(); + password.WrittenSpan.Length.ShouldBe(0); + password.GetPasswordString().ShouldBe(string.Empty); + } + + [AvaloniaFact] + public void Set_WithBytes_BecomesValidAndStoresBytes() + { + using var password = new SshKeyFilePassword(); + + password.Set("hunter2"u8); + + + password.IsValid.ShouldBeTrue(); + password.WrittenSpan.ToArray().ShouldBe("hunter2"u8.ToArray()); + password.GetPasswordString().ShouldBe("hunter2"); + } + + [AvaloniaFact] + public void Set_WithCustomEncoding_DecodesUsingThatEncoding() + { + using var password = new SshKeyFilePassword(); + var bytes = Encoding.Unicode.GetBytes("s3cr3t"); + + password.Set(bytes, Encoding.Unicode); + + + password.GetPasswordString().ShouldBe("s3cr3t"); + } + + [AvaloniaFact] + public void Set_CalledTwice_OverwritesPreviousValue() + { + using var password = new SshKeyFilePassword(); + + password.Set("first"u8); + password.Set("second"u8); + + + password.GetPasswordString().ShouldBe("second"); + } + + [AvaloniaFact] + public void Clear_ResetsToEmptyState() + { + using var password = new SshKeyFilePassword(); + password.Set("hunter2"u8); + + + password.Clear(); + + + password.IsValid.ShouldBeFalse(); + password.WrittenSpan.Length.ShouldBe(0); + password.GetPasswordString().ShouldBe(string.Empty); + } + + [AvaloniaFact] + public void Set_EmptyBytes_DoesNotBecomeValid() + { + using var password = new SshKeyFilePassword(); + + password.Set(ReadOnlySpan.Empty); + + + password.IsValid.ShouldBeFalse(); + } + + [AvaloniaFact] + public void ToSshKeyEncryption_WhenInvalid_ReturnsDefaultSshKeyEncryption() + { + using var password = new SshKeyFilePassword(); + + var encryption = password.ToSshKeyEncryption(); + + encryption.ShouldBeOfType(); + } + + [AvaloniaFact] + public void ToSshKeyEncryption_WhenValid_ReturnsAes256Encryption() + { + using var password = new SshKeyFilePassword(); + password.Set("hunter2"u8); + + + var encryption = password.ToSshKeyEncryption(); + + encryption.ShouldBeOfType(); + } + + [AvaloniaFact] + public void Dispose_ClearsBuffer() + { + var password = new SshKeyFilePassword(); + password.Set("hunter2"u8); + + + password.Dispose(); + + password.WrittenSpan.Length.ShouldBe(0); + } + + [AvaloniaFact] + public void Dispose_CalledTwice_DoesNotThrow() + { + var password = new SshKeyFilePassword(); + password.Set("hunter2"u8); + + + password.Dispose(); + Should.NotThrow(() => password.Dispose()); + } +} diff --git a/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFileSourceTests.cs b/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFileSourceTests.cs new file mode 100644 index 0000000..839a2a3 --- /dev/null +++ b/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFileSourceTests.cs @@ -0,0 +1,72 @@ +using Avalonia.Headless.XUnit; +using OpenSSH_GUI.Core.Lib.Keys; +using Shouldly; +using Xunit; + +namespace OpenSSH_GUI.Tests.Core.Lib.Keys; + +public class SshKeyFileSourceTests +{ + [AvaloniaFact] + public void FromDisk_SetsAbsolutePathAndNotProvidedByConfig() + { + var source = SshKeyFileSource.FromDisk("/tmp/id_rsa"); + + source.AbsolutePath.ShouldBe("/tmp/id_rsa"); + source.ProvidedByConfig.ShouldBeFalse(); + } + + [AvaloniaFact] + public void FromConfig_SetsAbsolutePathAndProvidedByConfig() + { + var source = SshKeyFileSource.FromConfig("/tmp/id_ed25519"); + + source.AbsolutePath.ShouldBe("/tmp/id_ed25519"); + source.ProvidedByConfig.ShouldBeTrue(); + } + + [AvaloniaFact] + public void Default_HasEmptyPathAndNotProvidedByConfig() + { + var source = new SshKeyFileSource(); + + source.AbsolutePath.ShouldBe(string.Empty); + source.ProvidedByConfig.ShouldBeFalse(); + } + + [AvaloniaFact] + public void ToString_ContainsPathAndConfigFlag() + { + var source = SshKeyFileSource.FromConfig("/tmp/key"); + + source.ToString().ShouldBe("/tmp/key | Referenced by Config: True"); + } + + [AvaloniaFact] + public void Equality_SameValues_AreEqual() + { + var a = SshKeyFileSource.FromDisk("/tmp/key"); + var b = SshKeyFileSource.FromDisk("/tmp/key"); + + a.ShouldBe(b); + (a == b).ShouldBeTrue(); + } + + [AvaloniaFact] + public void Equality_DifferentProvidedByConfig_AreNotEqual() + { + var a = SshKeyFileSource.FromDisk("/tmp/key"); + var b = SshKeyFileSource.FromConfig("/tmp/key"); + + a.ShouldNotBe(b); + } + + [AvaloniaFact] + public void Equality_DifferentPath_AreNotEqual() + { + var a = SshKeyFileSource.FromDisk("/tmp/key1"); + var b = SshKeyFileSource.FromDisk("/tmp/key2"); + + a.ShouldNotBe(b); + } +} diff --git a/OpenSSH_GUI.Tests/Core/MVVM/ViewModelBaseTests.cs b/OpenSSH_GUI.Tests/Core/MVVM/ViewModelBaseTests.cs index 273217a..a5de9a6 100644 --- a/OpenSSH_GUI.Tests/Core/MVVM/ViewModelBaseTests.cs +++ b/OpenSSH_GUI.Tests/Core/MVVM/ViewModelBaseTests.cs @@ -1,4 +1,5 @@ using System.Reactive.Linq; +using Avalonia.Headless.XUnit; using OpenSSH_GUI.Core.MVVM; using Xunit; @@ -6,7 +7,7 @@ namespace OpenSSH_GUI.Tests.Core.MVVM; public class ViewModelBaseTests { - [Fact] + [AvaloniaFact] public async Task InitializeAsync_ShouldSetIsInitialized() { // Arrange @@ -19,7 +20,7 @@ public async Task InitializeAsync_ShouldSetIsInitialized() Assert.True(vm.IsInitialized); } - [Fact] + [AvaloniaFact] public async Task BooleanSubmit_ShouldCallOnBooleanSubmitAsync() { // Arrange @@ -34,7 +35,7 @@ public async Task BooleanSubmit_ShouldCallOnBooleanSubmitAsync() Assert.False(vm.IsInitialized); } - [Fact] + [AvaloniaFact] public void RequestClose_ShouldInvokeCloseEvent() { // Arrange diff --git a/OpenSSH_GUI.Tests/OpenSSH_GUI.Tests.csproj b/OpenSSH_GUI.Tests/OpenSSH_GUI.Tests.csproj index 746a06f..11bd4af 100644 --- a/OpenSSH_GUI.Tests/OpenSSH_GUI.Tests.csproj +++ b/OpenSSH_GUI.Tests/OpenSSH_GUI.Tests.csproj @@ -3,9 +3,11 @@ false true Exe + true + @@ -24,6 +26,8 @@ + + diff --git a/OpenSSH_GUI.Tests/OpenSSH_GUI.Testss.csproj b/OpenSSH_GUI.Tests/OpenSSH_GUI.Testss.csproj new file mode 100644 index 0000000..be2c0e3 --- /dev/null +++ b/OpenSSH_GUI.Tests/OpenSSH_GUI.Testss.csproj @@ -0,0 +1,21 @@ + + + WinExe + net10.0 + enable + app.manifest + true + + + + + + + + + + None + All + + + diff --git a/OpenSSH_GUI.Tests/ReactiveUiInitFixture.cs b/OpenSSH_GUI.Tests/ReactiveUiInitFixture.cs index 87cc1d2..40b45ff 100644 --- a/OpenSSH_GUI.Tests/ReactiveUiInitFixture.cs +++ b/OpenSSH_GUI.Tests/ReactiveUiInitFixture.cs @@ -1,49 +1,49 @@ -using Avalonia; -using Avalonia.Headless; -using Avalonia.Threading; -using ReactiveUI.Builder; - -namespace OpenSSH_GUI.Tests; - -/// -/// Assembly-wide fixture that initializes ReactiveUI core services -/// before any test runs. Required because -/// and related types throw if ReactiveUI has not been bootstrapped. -/// -/// -/// Assembly-wide fixture that runs a dedicated Avalonia UI thread with a -/// live dispatcher loop. Required because -/// enforces UI-thread access, deadlocks -/// without a running message loop. -/// -public sealed class ReactiveUiInitFixture : IDisposable -{ - private readonly CancellationTokenSource _cts = new(); - private readonly ManualResetEventSlim _initialized = new(); - - public ReactiveUiInitFixture() - { - var uiThread = new Thread(() => - { - AppBuilder.Configure() - .UseHeadless(new AvaloniaHeadlessPlatformOptions()) - .SetupWithoutStarting(); - - RxAppBuilder.CreateReactiveUIBuilder() - .WithCoreServices() - .BuildApp(); - - _initialized.Set(); - - Dispatcher.UIThread.MainLoop(_cts.Token); - }); - - uiThread.IsBackground = true; - uiThread.Start(); - - _initialized.Wait(); - } - - /// - public void Dispose() { _cts.Cancel(); } -} \ No newline at end of file +// using Avalonia; +// using Avalonia.Headless; +// using Avalonia.Threading; +// using ReactiveUI.Builder; +// +// namespace OpenSSH_GUI.Tests; +// +// /// +// /// Assembly-wide fixture that initializes ReactiveUI core services +// /// before any test runs. Required because +// /// and related types throw if ReactiveUI has not been bootstrapped. +// /// +// /// +// /// Assembly-wide fixture that runs a dedicated Avalonia UI thread with a +// /// live dispatcher loop. Required because +// /// enforces UI-thread access, deadlocks +// /// without a running message loop. +// /// +// public sealed class ReactiveUiInitFixture : IDisposable +// { +// private readonly CancellationTokenSource _cts = new(); +// private readonly ManualResetEventSlim _initialized = new(); +// +// public ReactiveUiInitFixture() +// { +// var uiThread = new Thread(() => +// { +// AppBuilder.Configure() +// .UseHeadless(new AvaloniaHeadlessPlatformOptions()) +// .SetupWithoutStarting(); +// +// RxAppBuilder.CreateReactiveUIBuilder() +// .WithCoreServices() +// .BuildApp(); +// +// _initialized.Set(); +// +// Dispatcher.UIThread.MainLoop(_cts.Token); +// }); +// +// uiThread.IsBackground = true; +// uiThread.Start(); +// +// _initialized.Wait(); +// } +// +// /// +// public void Dispose() { _cts.Cancel(); } +// } \ No newline at end of file diff --git a/OpenSSH_GUI.Tests/SshConfig/SshConfigParserTests.cs b/OpenSSH_GUI.Tests/SshConfig/SshConfigParserTests.cs index 99a716f..998219d 100644 --- a/OpenSSH_GUI.Tests/SshConfig/SshConfigParserTests.cs +++ b/OpenSSH_GUI.Tests/SshConfig/SshConfigParserTests.cs @@ -1,3 +1,4 @@ +using Avalonia.Headless.XUnit; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.FileProviders; using OpenSSH_GUI.Core.Extensions; @@ -28,7 +29,7 @@ private string GetEmbeddedResource(string fileName) return reader.ReadToEnd(); } - [Fact] + [AvaloniaFact] public void Parse_GlobalConfig_ShouldParseEmbeddedFile() { var content = GetEmbeddedResource("ssh_config_global"); @@ -44,7 +45,7 @@ public void Parse_GlobalConfig_ShouldParseEmbeddedFile() hostStar.GetEntries().ShouldContain(e => e.Key == "ConnectTimeout" && e.Value == "20"); } - [Fact] + [AvaloniaFact] public void Parse_PersonalConfig_Into_Config_DependencyInjection() { var sc = new ConfigurationBuilder(); @@ -61,7 +62,7 @@ public void Parse_PersonalConfig_Into_Config_DependencyInjection() count.ShouldBeGreaterThan(0); } - [Fact] + [AvaloniaFact] public void Parse_PersonalConfig_ShouldParseEmbeddedFile() { var content = GetEmbeddedResource("ssh_config_personal"); @@ -76,7 +77,7 @@ public void Parse_PersonalConfig_ShouldParseEmbeddedFile() prodWeb01.GetEntries().ShouldContain(e => e.Key == "HostName" && e.Value == "10.10.1.11"); } - [Fact] + [AvaloniaFact] public void Parse_SshdServerConfig_ShouldParseEmbeddedFile() { var content = GetEmbeddedResource("sshd_config_server"); @@ -96,7 +97,7 @@ public void Parse_SshdServerConfig_ShouldParseEmbeddedFile() userDeploy.GetEntries().ShouldContain(e => e.Key == "AllowTcpForwarding" && e.Value == "no"); } - [Fact] + [AvaloniaFact] public void Parse_EmptyContent_ShouldReturnEmptyDocument() { var doc = SshConfigParser.Parse(string.Empty); @@ -104,7 +105,7 @@ public void Parse_EmptyContent_ShouldReturnEmptyDocument() doc.Blocks.ShouldBeEmpty(); } - [Fact] + [AvaloniaFact] public void Parse_SimpleHostBlock_ShouldParseCorrectly() { var content = @" @@ -125,7 +126,7 @@ User alice entries[1].Value.ShouldBe("alice"); } - [Fact] + [AvaloniaFact] public void Parse_GlobalItems_ShouldParseCorrectly() { var content = @" @@ -143,7 +144,7 @@ User alice doc.Blocks.Length.ShouldBe(1); } - [Fact] + [AvaloniaFact] public void Parse_Comments_ShouldBePreserved() { var content = @"# Global comment @@ -163,7 +164,7 @@ User alice hostBlock.HeaderComment.ShouldBe("# host comment"); } - [Fact] + [AvaloniaFact] public void Parse_MultipleHosts_ShouldParseCorrectly() { var content = "Host host1 host2\n User bob"; @@ -174,7 +175,7 @@ public void Parse_MultipleHosts_ShouldParseCorrectly() hostBlock.Patterns.ShouldContain("host2"); } - [Fact] + [AvaloniaFact] public void Parse_MatchBlock_ShouldParseCorrectly() { var content = "Match host example.com user root\n Port 22"; @@ -188,7 +189,7 @@ public void Parse_MatchBlock_ShouldParseCorrectly() matchBlock.Criteria[1].Pattern.ShouldBe("root"); } - [Fact] + [AvaloniaFact] public void GetConnectionEntriesFromConfig_ShouldReturnCredentials() { var content = @" @@ -215,7 +216,7 @@ HostName 5.6.7.8 keyHost.GetType().ShouldBe(typeof(KeyConnectionCredentials)); } - [Fact] + [AvaloniaFact] public void GetConnectionEntriesFromConfig_WithPersonalConfig_ShouldReturnCredentials() { var content = GetEmbeddedResource("ssh_config_personal"); @@ -231,7 +232,7 @@ public void GetConnectionEntriesFromConfig_WithPersonalConfig_ShouldReturnCreden keyHost.ShouldNotBeNull(); } - [Fact] + [AvaloniaFact] public void Parse_IncludeRecursion_ShouldThrow() { // Arrange @@ -255,7 +256,7 @@ public void Parse_IncludeRecursion_ShouldThrow() } } - [Fact] + [AvaloniaFact] public void Parse_UnknownKey_WithStrictOptions_ShouldThrow() { // Arrange @@ -266,7 +267,7 @@ public void Parse_UnknownKey_WithStrictOptions_ShouldThrow() Assert.Throws(() => SshConfigParser.Parse(content, options)); } - [Fact] + [AvaloniaFact] public void Parse_InvalidPort_ShouldBeHandledInSettings() { // Arrange @@ -285,7 +286,7 @@ public void Parse_InvalidPort_ShouldBeHandledInSettings() Assert.Equal("Port", settings.OtherEntries[0].Key); } - [Fact] + [AvaloniaFact] public void Parse_QuotedValues_ShouldStripDoubleQuotes() { // Arrange @@ -303,7 +304,7 @@ public void Parse_QuotedValues_ShouldStripDoubleQuotes() Assert.Contains("~/.ssh/id rsa", settings.IdentityFiles); } - [Fact] + [AvaloniaFact] public void Parse_EmptyLinesAndComments_ShouldPreserve() { // Arrange @@ -326,7 +327,7 @@ public void Parse_EmptyLinesAndComments_ShouldPreserve() Assert.IsType(block.Items[3]); } - [Fact] + [AvaloniaFact] public void Parse_MatchCriteria_AllSupported() { // Arrange @@ -345,7 +346,7 @@ public void Parse_MatchCriteria_AllSupported() Assert.Contains(block.Criteria, c => c.Kind == SshMatchCriterionKind.Address); } - [Fact] + [AvaloniaFact] public void Parse_MatchCriteria_Invalid_ShouldThrow() { // Arrange diff --git a/OpenSSH_GUI.Tests/SshConfig/SshConfigSerializerTests.cs b/OpenSSH_GUI.Tests/SshConfig/SshConfigSerializerTests.cs index 2aa1a0d..60a343a 100644 --- a/OpenSSH_GUI.Tests/SshConfig/SshConfigSerializerTests.cs +++ b/OpenSSH_GUI.Tests/SshConfig/SshConfigSerializerTests.cs @@ -1,3 +1,4 @@ +using Avalonia.Headless.XUnit; using OpenSSH_GUI.SshConfig.Models; using OpenSSH_GUI.SshConfig.Options; using OpenSSH_GUI.SshConfig.Parsers; @@ -9,7 +10,7 @@ namespace OpenSSH_GUI.Tests.SshConfig; public class SshConfigSerializerTests { - [Fact] + [AvaloniaFact] public void Serialize_SimpleDocument_ShouldProduceCorrectOutput() { var doc = new SshConfigDocument( @@ -28,7 +29,7 @@ public void Serialize_SimpleDocument_ShouldProduceCorrectOutput() output.ShouldContain(" User alice"); } - [Fact] + [AvaloniaFact] public void Serialize_RoundTrip_ShouldPreserveFormatting() { var input = @@ -39,7 +40,7 @@ public void Serialize_RoundTrip_ShouldPreserveFormatting() output.Replace("\r\n", "\n").Trim().ShouldBe(input.Replace("\r\n", "\n").Trim()); } - [Fact] + [AvaloniaFact] public void Serialize_MatchBlock_ShouldProduceCorrectOutput() { var criteria = new[] @@ -55,7 +56,7 @@ public void Serialize_MatchBlock_ShouldProduceCorrectOutput() output.ShouldContain("Match host example.com user root"); } - [Fact] + [AvaloniaFact] public void Serialize_CleanMode_WithOptions_ShouldFormatCorrectly() { // Arrange @@ -78,7 +79,7 @@ public void Serialize_CleanMode_WithOptions_ShouldFormatCorrectly() Assert.Equal("Host example\n\tUser=alice\n", output); } - [Fact] + [AvaloniaFact] public void Serialize_RoundTrip_WithModifications_ShouldRegenerate() { // Arrange @@ -117,7 +118,7 @@ public void Serialize_RoundTrip_WithModifications_ShouldRegenerate() Assert.DoesNotContain("User alice", output); } - [Fact] + [AvaloniaFact] public void QuoteIfNeeded_ShouldQuoteWhitespace() { // Arrange diff --git a/OpenSSH_GUI.Tests/SshConfig/SshConfigurationBindingTests.cs b/OpenSSH_GUI.Tests/SshConfig/SshConfigurationBindingTests.cs index 2c22f27..24bea66 100644 --- a/OpenSSH_GUI.Tests/SshConfig/SshConfigurationBindingTests.cs +++ b/OpenSSH_GUI.Tests/SshConfig/SshConfigurationBindingTests.cs @@ -1,3 +1,4 @@ +using Avalonia.Headless.XUnit; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.FileProviders; using OpenSSH_GUI.SshConfig.Extensions; @@ -11,7 +12,7 @@ public class SshConfigurationBindingTests { private static IFileProvider GetEmbeddedFileProvider() => new EmbeddedFileProvider(typeof(SshConfigParserTests).Assembly, "OpenSSH_GUI.Tests.Assets.Testfiles"); - [Fact] + [AvaloniaFact] public void AddSshConfig_ShouldBeBindableToObjects() { // Arrange diff --git a/OpenSSH_GUI.Tests/SshConfig/SshConfigurationTests.cs b/OpenSSH_GUI.Tests/SshConfig/SshConfigurationTests.cs index 67c996a..f00e9ff 100644 --- a/OpenSSH_GUI.Tests/SshConfig/SshConfigurationTests.cs +++ b/OpenSSH_GUI.Tests/SshConfig/SshConfigurationTests.cs @@ -1,3 +1,4 @@ +using Avalonia.Headless.XUnit; using Microsoft.Extensions.Configuration; using OpenSSH_GUI.SshConfig.Extensions; using Shouldly; @@ -7,7 +8,7 @@ namespace OpenSSH_GUI.Tests.SshConfig; public class SshConfigurationTests { - [Fact] + [AvaloniaFact] public void AddSshConfig_ShouldLoadGlobalEntries() { // Arrange @@ -33,7 +34,7 @@ public void AddSshConfig_ShouldLoadGlobalEntries() } } - [Fact] + [AvaloniaFact] public void AddSshConfig_ShouldLoadHostBlocks() { // Arrange @@ -62,7 +63,7 @@ public void AddSshConfig_ShouldLoadHostBlocks() } } - [Fact] + [AvaloniaFact] public void AddSshConfig_Optional_ShouldNotThrowIfFileMissing() { // Arrange diff --git a/OpenSSH_GUI.Tests/SshConfig/SshHostSettingsTests.cs b/OpenSSH_GUI.Tests/SshConfig/SshHostSettingsTests.cs index 79f946a..c3a7bab 100644 --- a/OpenSSH_GUI.Tests/SshConfig/SshHostSettingsTests.cs +++ b/OpenSSH_GUI.Tests/SshConfig/SshHostSettingsTests.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using Avalonia.Headless.XUnit; using OpenSSH_GUI.SshConfig.Extensions; using OpenSSH_GUI.SshConfig.Models; using Xunit; @@ -7,7 +8,7 @@ namespace OpenSSH_GUI.Tests.SshConfig; public class SshHostSettingsTests { - [Fact] + [AvaloniaFact] public void GetSettings_ShouldMapCommonEntries() { // Arrange @@ -40,7 +41,7 @@ public void GetSettings_ShouldMapCommonEntries() Assert.Equal("Compression", settings.OtherEntries?[0].Key); } - [Fact] + [AvaloniaFact] public void WithSettings_ShouldUpdateBlock() { // Arrange @@ -70,7 +71,7 @@ public void WithSettings_ShouldUpdateBlock() Assert.Equal("9000 localhost:90", reserializedSettings.LocalForwards[0]); } - [Fact] + [AvaloniaFact] public void EmptySettings_ShouldBeCorrect() { // Arrange & Act diff --git a/OpenSSH_GUI.Tests/SshConfig/SshKnownKeysTests.cs b/OpenSSH_GUI.Tests/SshConfig/SshKnownKeysTests.cs index 86fb65f..7ec4fe0 100644 --- a/OpenSSH_GUI.Tests/SshConfig/SshKnownKeysTests.cs +++ b/OpenSSH_GUI.Tests/SshConfig/SshKnownKeysTests.cs @@ -1,3 +1,4 @@ +using Avalonia.Headless.XUnit; using OpenSSH_GUI.SshConfig.Models; using Shouldly; using Xunit; @@ -6,15 +7,15 @@ namespace OpenSSH_GUI.Tests.SshConfig; public class SshKnownKeysTests { - [Theory, InlineData("hostname", "HostName"), InlineData("USER", "User"), InlineData("identityfile", "IdentityFile"), InlineData("UNKNOWN", "UNKNOWN")] + [AvaloniaTheory, InlineData("hostname", "HostName"), InlineData("USER", "User"), InlineData("identityfile", "IdentityFile"), InlineData("UNKNOWN", "UNKNOWN")] public void Normalize_ShouldCanonicalizeCasing(string input, string expected) { SshKnownKeys.Normalize(input).ShouldBe(expected); } - [Theory, InlineData("IdentityFile", true), InlineData("HostName", false)] + [AvaloniaTheory, InlineData("IdentityFile", true), InlineData("HostName", false)] public void IsMultiOccurrenceKey_Tests(string key, bool expected) { SshKnownKeys.IsMultiOccurrenceKey(key).ShouldBe(expected); } - [Theory, InlineData("SendEnv", true), InlineData("HostName", false)] + [AvaloniaTheory, InlineData("SendEnv", true), InlineData("HostName", false)] public void IsMultiTokenKey_Tests(string key, bool expected) { SshKnownKeys.IsMultiTokenKey(key).ShouldBe(expected); } - [Theory, InlineData("HostName", true), InlineData("SomethingRandom", false)] + [AvaloniaTheory, InlineData("HostName", true), InlineData("SomethingRandom", false)] public void IsKnownKey_Tests(string key, bool expected) { SshKnownKeys.IsKnownKey(key).ShouldBe(expected); } } \ No newline at end of file diff --git a/OpenSSH_GUI.Tests/SshConfig/SshTokenExpanderTests.cs b/OpenSSH_GUI.Tests/SshConfig/SshTokenExpanderTests.cs index 5a11d08..631b046 100644 --- a/OpenSSH_GUI.Tests/SshConfig/SshTokenExpanderTests.cs +++ b/OpenSSH_GUI.Tests/SshConfig/SshTokenExpanderTests.cs @@ -1,3 +1,4 @@ +using Avalonia.Headless.XUnit; using OpenSSH_GUI.SshConfig.Parsers; using Shouldly; using Xunit; @@ -6,7 +7,7 @@ namespace OpenSSH_GUI.Tests.SshConfig; public class SshTokenExpanderTests { - [Fact] + [AvaloniaFact] public void Expand_NoTokens_ShouldReturnOriginalString() { var context = new SshTokenContext(); @@ -14,7 +15,7 @@ public void Expand_NoTokens_ShouldReturnOriginalString() result.ShouldBe("nothing to see here"); } - [Fact] + [AvaloniaFact] public void Expand_PercentPercent_ShouldReturnSinglePercent() { var context = new SshTokenContext(); @@ -22,7 +23,7 @@ public void Expand_PercentPercent_ShouldReturnSinglePercent() result.ShouldBe("100% sure"); } - [Fact] + [AvaloniaFact] public void Expand_CommonTokens_ShouldSubstituteCorrectly() { var context = new SshTokenContext( @@ -36,7 +37,7 @@ public void Expand_CommonTokens_ShouldSubstituteCorrectly() SshTokenExpander.Expand("user is %u", context).ShouldBe("user is bob"); } - [Fact] + [AvaloniaFact] public void Expand_UnrecognizedToken_ShouldKeepUnchanged() { var context = new SshTokenContext(); @@ -44,7 +45,7 @@ public void Expand_UnrecognizedToken_ShouldKeepUnchanged() result.ShouldBe("token %z is unknown"); } - [Fact] + [AvaloniaFact] public void Expand_TrailingPercent_ShouldKeepUnchanged() { var context = new SshTokenContext(); @@ -52,7 +53,7 @@ public void Expand_TrailingPercent_ShouldKeepUnchanged() result.ShouldBe("ends with %"); } - [Fact] + [AvaloniaFact] public void Expand_AllTokens_ShouldSubstituteCorrectly() { var context = new SshTokenContext( diff --git a/OpenSSH_GUI.Tests/SshConfig/SshWildcardMatcherTests.cs b/OpenSSH_GUI.Tests/SshConfig/SshWildcardMatcherTests.cs index b52c5e0..3f6ad71 100644 --- a/OpenSSH_GUI.Tests/SshConfig/SshWildcardMatcherTests.cs +++ b/OpenSSH_GUI.Tests/SshConfig/SshWildcardMatcherTests.cs @@ -1,3 +1,4 @@ +using Avalonia.Headless.XUnit; using OpenSSH_GUI.SshConfig.Parsers; using Shouldly; using Xunit; @@ -6,13 +7,13 @@ namespace OpenSSH_GUI.Tests.SshConfig; public class SshWildcardMatcherTests { - [Theory, InlineData("example.com", "example.com", true), InlineData("example.com", "*.com", true), InlineData("example.com", "example.*", true), + [AvaloniaTheory, InlineData("example.com", "example.com", true), InlineData("example.com", "*.com", true), InlineData("example.com", "example.*", true), InlineData("example.com", "*example*", true), InlineData("example.com", "ex?mple.com", true), InlineData("example.com", "other.com", false), InlineData("abc", "a?c", true), InlineData("abc", "a*", true), InlineData("abc", "*c", true), InlineData("abc", "*", true), InlineData("abc", "abcd", false), InlineData("abc", "ab", false), InlineData("", "*", true), InlineData("a", "", false), InlineData("", "", true), InlineData("abc", "***", true), InlineData("abc", "*b*", true), InlineData("abc", "a**c", true)] public void MatchesGlob_Tests(string input, string pattern, bool expected) { SshWildcardMatcher.MatchesGlob(input.AsSpan(), pattern.AsSpan()).ShouldBe(expected); } - [Theory, InlineData( + [AvaloniaTheory, InlineData( "host1", new[] { "host1", "host2" diff --git a/OpenSSH_GUI.Tests/TestAssemblySetup.cs b/OpenSSH_GUI.Tests/TestAssemblySetup.cs index 31d4918..c209866 100644 --- a/OpenSSH_GUI.Tests/TestAssemblySetup.cs +++ b/OpenSSH_GUI.Tests/TestAssemblySetup.cs @@ -1,4 +1,4 @@ using OpenSSH_GUI.Tests; using Xunit; -[assembly: AssemblyFixture(typeof(ReactiveUiInitFixture))] \ No newline at end of file +// [assembly: AssemblyFixture(typeof(ReactiveUiInitFixture))] \ No newline at end of file diff --git a/OpenSSH_GUI.Tests/ViewModels/_SmokeTests.cs b/OpenSSH_GUI.Tests/ViewModels/_SmokeTests.cs new file mode 100644 index 0000000..788a7f3 --- /dev/null +++ b/OpenSSH_GUI.Tests/ViewModels/_SmokeTests.cs @@ -0,0 +1,23 @@ +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Threading; +using Shouldly; +using Xunit; + +namespace OpenSSH_GUI.Tests.ViewModels; + +public class SmokeTests +{ + [AvaloniaFact] + public async Task Clipboard_Roundtrip_Works_On_Headless_Window() + { + var window = new Window(); + window.Clipboard.ShouldNotBeNull(); + await window.Clipboard!.SetValueAsync(DataFormat.Text, "hello-world"); + await window.Clipboard!.FlushAsync(); + var text = await window.Clipboard!.TryGetTextAsync(); + text.ShouldBe("hello-world"); + } +} diff --git a/OpenSSH_GUI.slnx b/OpenSSH_GUI.slnx index 08d6113..3866093 100644 --- a/OpenSSH_GUI.slnx +++ b/OpenSSH_GUI.slnx @@ -4,33 +4,42 @@ + + + + + + + + + + + + - - - - + + + - - - + diff --git a/OpenSSH_GUI/App.axaml.cs b/OpenSSH_GUI/App.axaml.cs index 2fc7291..a604d53 100644 --- a/OpenSSH_GUI/App.axaml.cs +++ b/OpenSSH_GUI/App.axaml.cs @@ -127,6 +127,7 @@ public override async void OnFrameworkInitializationCompleted() logger.LogError(e, "Error creating app icons"); throw; } + iconStore.Freeze(); try { diff --git a/OpenSSH_GUI/OpenSSH_GUI.csproj b/OpenSSH_GUI/OpenSSH_GUI.csproj index 6269eee..7c3a5a5 100644 --- a/OpenSSH_GUI/OpenSSH_GUI.csproj +++ b/OpenSSH_GUI/OpenSSH_GUI.csproj @@ -1,76 +1,78 @@  - - - WinExe - true - app.manifest - true - true - true - true - ../images/openssh-gui.ico - false - frequency403 - en - OpenSSH-GUI.snk - false - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - Assets/%(Filename)%(Extension) - - - - - - PublicResXFileCodeGenerator - StringsAndTexts.Designer.cs - - - - - True - True - StringsAndTexts.resx - - - SubmitButtons.axaml - Code - - - - - OpenSSH GUI - - OpenSSH GUI - frequency403.opensshgui - 1.0.0 - APPL - OpenSSHGui - OpenSSHGui.icns - - NSApplication - true - + + + WinExe + true + app.manifest + true + true + true + true + ../images/openssh-gui.ico + false + frequency403 + en + OpenSSH-GUI.snk + false + true + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + Assets/%(Filename)%(Extension) + + + + + + PublicResXFileCodeGenerator + StringsAndTexts.Designer.cs + + + + + True + True + StringsAndTexts.resx + + + SubmitButtons.axaml + Code + + + + + OpenSSH GUI + + OpenSSH GUI + frequency403.opensshgui + 1.0.0 + APPL + OpenSSHGui + OpenSSHGui.icns + + NSApplication + true + \ No newline at end of file diff --git a/OpenSSH_GUI/Program.cs b/OpenSSH_GUI/Program.cs index f072e6f..b11d527 100644 --- a/OpenSSH_GUI/Program.cs +++ b/OpenSSH_GUI/Program.cs @@ -28,11 +28,10 @@ internal sealed class Program public const string AppName = "OpenSSH GUI"; public const string VersionEnvVar = "RUNNING_VERSION"; - private static string GetHostVersion() => Assembly.GetEntryAssembly() - ?.GetCustomAttribute() - ?.InformationalVersion - ?? Assembly.GetEntryAssembly()?.GetName().Version?.ToString() - ?? "0.0.0"; + private static string GetHostVersion(Assembly? assembly) => assembly?.GetCustomAttribute() + ?.InformationalVersion + ?? Assembly.GetEntryAssembly()?.GetName().Version?.ToString() + ?? "0.0.0"; private static void ConfigureOpenSshGuiLogger( ApplicationConfiguration bootstrapConfig, @@ -59,7 +58,7 @@ private static void ConfigureOpenSshGuiLogger( #pragma warning disable CA1416 [STAThread] - public static async Task Main(string[] args) + public static void Main(string[] args) { using var mainCancellationTokenSource = new CancellationTokenSource(); @@ -91,9 +90,9 @@ public static async Task Main(string[] args) .WithExceptionHandler(host.Services.GetRequiredService()); }); - await host.StartAsync(mainCancellationTokenSource.Token); + host.Start(); appBuilder.StartWithClassicDesktopLifetime(args); - await host.StopAsync(mainCancellationTokenSource.Token); + host.StopAsync(mainCancellationTokenSource.Token).GetAwaiter().GetResult(); } #pragma warning restore CA1416 @@ -108,7 +107,7 @@ private static void ConfigureAppConfiguration(HostBuilderContext hostBuilderCont configurationBuilder.AddInMemoryCollection( [ - new KeyValuePair(VersionEnvVar, GetHostVersion()) + new KeyValuePair(VersionEnvVar, GetHostVersion(Assembly.GetEntryAssembly())) ]); } diff --git a/OpenSSH_GUI/ViewModels/ApplicationSettingsViewModel.cs b/OpenSSH_GUI/ViewModels/ApplicationSettingsViewModel.cs index eccba23..c97e0e8 100644 --- a/OpenSSH_GUI/ViewModels/ApplicationSettingsViewModel.cs +++ b/OpenSSH_GUI/ViewModels/ApplicationSettingsViewModel.cs @@ -155,7 +155,7 @@ private Task DeleteLookupPathAsync(string path, CancellationToken cancellationTo [ReactiveCommand] private async Task AddLookupPathAsync(CancellationToken cancellationToken = default) { - if (OwnerTopLevel is { StorageProvider: { } storageProvider} && await storageProvider.OpenFolderPickerAsync( + if (GetTopLevel() is { StorageProvider: { } storageProvider} && await storageProvider.OpenFolderPickerAsync( new FolderPickerOpenOptions { AllowMultiple = false @@ -276,7 +276,7 @@ private void DeleteOldLogFiles() } [ReactiveCommand] - private async Task OpenCacheFolder(CancellationToken token = default) => OwnerTopLevel is { Launcher: { } launcher } + private async Task OpenCacheFolder(CancellationToken token = default) => GetTopLevel() is { Launcher: { } launcher } && await launcher.LaunchDirectoryInfoAsync( new DirectoryInfo( Path.Combine( diff --git a/OpenSSH_GUI/ViewModels/ExportWindowViewModel.cs b/OpenSSH_GUI/ViewModels/ExportWindowViewModel.cs index f961bb1..27769cb 100644 --- a/OpenSSH_GUI/ViewModels/ExportWindowViewModel.cs +++ b/OpenSSH_GUI/ViewModels/ExportWindowViewModel.cs @@ -1,6 +1,9 @@ -using Avalonia.Input.Platform; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Threading; using JetBrains.Annotations; using Microsoft.Extensions.Logging; +using OpenSSH_GUI.Core.Lib.Misc; using OpenSSH_GUI.Core.MVVM; using ReactiveUI.SourceGenerators; @@ -27,9 +30,9 @@ protected override async Task BooleanSubmitAsync(bool inputParameter, { try { - if (inputParameter && OwnerTopLevel is { Clipboard: { } clipboard }) + if (inputParameter && GetTopLevel() is { Clipboard: { } clipboard }) { - await clipboard.SetTextAsync(Export); + await clipboard.SetValueAsync(DataFormat.Text, Export); await clipboard.FlushAsync(); } } diff --git a/OpenSSH_GUI/ViewModels/FileInfoWindowViewModel.cs b/OpenSSH_GUI/ViewModels/FileInfoWindowViewModel.cs index 2228c6f..379ce83 100644 --- a/OpenSSH_GUI/ViewModels/FileInfoWindowViewModel.cs +++ b/OpenSSH_GUI/ViewModels/FileInfoWindowViewModel.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using System.Reactive.Disposables.Fluent; using System.Reactive.Linq; +using Avalonia.Input; using Avalonia.Input.Platform; using DynamicData; using JetBrains.Annotations; @@ -8,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OpenSSH_GUI.Core.Lib.Keys; +using OpenSSH_GUI.Core.Lib.Misc; using OpenSSH_GUI.Core.MVVM; using OpenSSH_GUI.Core.Services; using OpenSSH_GUI.Dialogs.Enums; @@ -238,9 +240,9 @@ private async Task CopyPasswordIntoClipboardAsync(SshKeyFilePassword password, C { try { - if (OwnerTopLevel is { Clipboard: { } clipboard }) + if (GetTopLevel() is { Clipboard: { } clipboard }) { - await clipboard.SetTextAsync(password.GetPasswordString()); + await clipboard.SetValueAsync(DataFormat.Text, Password); await clipboard.FlushAsync(); } diff --git a/OpenSSH_GUI/ViewModels/MainWindowViewModel.cs b/OpenSSH_GUI/ViewModels/MainWindowViewModel.cs index 2aaadc0..8650dfc 100644 --- a/OpenSSH_GUI/ViewModels/MainWindowViewModel.cs +++ b/OpenSSH_GUI/ViewModels/MainWindowViewModel.cs @@ -229,7 +229,7 @@ private async Task OpenBrowserAsync(int commandTypeParameter, CancellationToken break; } - if(OwnerTopLevel is { Launcher: { } launcher}) + if(GetTopLevel() is { Launcher: { } launcher}) await launcher.LaunchUriAsync(uriBuilder.Uri); } diff --git a/appimage/AppImageBuilder.yml b/appimage/AppImageBuilder.yml deleted file mode 100644 index 426592a..0000000 --- a/appimage/AppImageBuilder.yml +++ /dev/null @@ -1,19 +0,0 @@ -version: 1 - -AppDir: - path: ./AppDir - - app_info: - id: io.github.frequency403.openssh_gui - name: OpenSSH GUI - icon: openssh-gui - version: ${APPSTREAM_VERSION} - exec: usr/bin/openssh-gui - exec_args: "$@" - - runtime: - env: - APPDIR_LIBRARY_PATH: "$APPDIR/usr/lib" - -AppImage: - arch: x86_64 diff --git a/openssh-gui-git/PKGBUILD b/openssh-gui-git/PKGBUILD index 6d2ef41..e44aa4e 100644 --- a/openssh-gui-git/PKGBUILD +++ b/openssh-gui-git/PKGBUILD @@ -1,26 +1,52 @@ pkgname=openssh-gui-git _pkgname=OpenSSH-GUI -pkgver=3.2.0.20260612.a1b2c3d +_pkgdirname=OpenSSH_GUI +pkgver=3.2.3.274+ca203b0 pkgrel=1 pkgdesc="A GUI for OpenSSH configuration and management (Sourcepackage)" arch=('x86_64') url="https://github.com/frequency403/OpenSSH-GUI" license=('MIT') -options=('!strip') +options=('!strip') depends=('icu' 'openssl' 'zlib' 'krb5' 'libx11') makedepends=('git' 'dotnet-sdk-10.0' 'librsvg') provides=('openssh-gui') -conflicts=('openssh-gui' 'openssh-gui-bin' 'openssh-gui-nightly') +conflicts=('openssh-gui' 'openssh-gui-bin') source=("git+${url}.git#branch=development") sha256sums=('SKIP') +prepare() { + cd "${srcdir}/${_pkgname}" + git fetch --all + git checkout development + git reset --hard origin/development + dotnet restore ${_pkgdirname}/${_pkgdirname}.csproj +} + +pkgver() { + cd "${srcdir}/${_pkgname}" + git fetch --all -q + git reset --hard origin/development -q + local version + version=$(dotnet msbuild ${_pkgdirname}/${_pkgdirname}.csproj \ + -nologo \ + -restore:false \ + -getProperty:Version \ + | tr -d '\r' \ + | tr '-' '.') + printf "%s.%s+%s" \ + "$version" \ + "$(git rev-list --count HEAD)" \ + "$(git rev-parse --short HEAD)" +} + build() { cd "${srcdir}/${_pkgname}" - dotnet publish OpenSSH_GUI/OpenSSH_GUI.csproj \ + dotnet publish ${_pkgdirname}/${_pkgdirname}.csproj \ --configuration Release \ --runtime linux-x64 \ --output publish \ @@ -28,7 +54,8 @@ build() { -p:PublishSingleFile=true \ -p:PublishReadyToRun=false \ -p:IncludeNativeLibrariesForSelfExtract=true \ - -p:InformationalVersion="${pkgver}" + -p:DebugType=none \ + -p:Version="${pkgver}" rsvg-convert -w 256 -h 256 images/openssh-gui.svg -o appicon-256.png } @@ -36,7 +63,7 @@ build() { package() { cd "${srcdir}/${_pkgname}" - install -Dm755 "publish/OpenSSH_GUI" \ + install -Dm755 "publish/${_pkgdirname}" \ "${pkgdir}/usr/bin/openssh-gui" install -Dm644 "appicon-256.png" \ @@ -50,4 +77,4 @@ package() { install -Dm644 "LICENSE" \ "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" -} \ No newline at end of file +}