From 5dd02cee660894700deb664591877c77ae93da11 Mon Sep 17 00:00:00 2001 From: Thomas Williamson Date: Tue, 23 Dec 2025 14:06:27 +0100 Subject: [PATCH 01/11] Add SFTP Connector functionality and related components --- .../ExtSFTPConnector.Entitlement.al | 13 + .../App/ExtensionLogo.png | Bin 0 -> 4681 bytes .../App/README.md | 2 + .../App/app.json | 43 ++ .../App/data/connector-logo.png | Bin 0 -> 35449 bytes .../permissions/ExtSFTPEdit.PermissionSet.al | 17 + .../ExtSFTPObjects.PermissionSet.al | 17 + .../permissions/ExtSFTPRead.PermissionSet.al | 17 + ...ileStorageAdminExtSFTP.PermissionSetExt.al | 11 + ...FileStorageEditExtSFTP.PermissionSetExt.al | 11 + .../App/src/ExtSFTPAccount.Page.al | 162 ++++++ .../App/src/ExtSFTPAccount.Table.al | 209 ++++++++ .../App/src/ExtSFTPAccountWizard.Page.al | 253 +++++++++ .../App/src/ExtSFTPAuthType.Enum.al | 31 ++ .../App/src/ExtSFTPConnector.EnumExt.al | 21 + .../App/src/ExtSFTPConnectorImpl.Codeunit.al | 483 ++++++++++++++++++ .../Test/ExtensionLogo.png | Bin 0 -> 4681 bytes .../Test/README.md | 0 .../Test/app.json | 58 +++ .../Test/src/ExtSFTPConnectorTest.Codeunit.al | 162 ++++++ .../src/mocks/ExtSFTPAccountMock.Codeunit.al | 91 ++++ 21 files changed, 1601 insertions(+) create mode 100644 src/Apps/W1/External File Storage - SFTP Connector/App/Entitlements/ExtSFTPConnector.Entitlement.al create mode 100644 src/Apps/W1/External File Storage - SFTP Connector/App/ExtensionLogo.png create mode 100644 src/Apps/W1/External File Storage - SFTP Connector/App/README.md create mode 100644 src/Apps/W1/External File Storage - SFTP Connector/App/app.json create mode 100644 src/Apps/W1/External File Storage - SFTP Connector/App/data/connector-logo.png create mode 100644 src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPEdit.PermissionSet.al create mode 100644 src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPObjects.PermissionSet.al create mode 100644 src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPRead.PermissionSet.al create mode 100644 src/Apps/W1/External File Storage - SFTP Connector/App/permissions/FileStorageAdminExtSFTP.PermissionSetExt.al create mode 100644 src/Apps/W1/External File Storage - SFTP Connector/App/permissions/FileStorageEditExtSFTP.PermissionSetExt.al create mode 100644 src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Page.al create mode 100644 src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al create mode 100644 src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al create mode 100644 src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAuthType.Enum.al create mode 100644 src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnector.EnumExt.al create mode 100644 src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al create mode 100644 src/Apps/W1/External File Storage - SFTP Connector/Test/ExtensionLogo.png create mode 100644 src/Apps/W1/External File Storage - SFTP Connector/Test/README.md create mode 100644 src/Apps/W1/External File Storage - SFTP Connector/Test/app.json create mode 100644 src/Apps/W1/External File Storage - SFTP Connector/Test/src/ExtSFTPConnectorTest.Codeunit.al create mode 100644 src/Apps/W1/External File Storage - SFTP Connector/Test/src/mocks/ExtSFTPAccountMock.Codeunit.al diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/Entitlements/ExtSFTPConnector.Entitlement.al b/src/Apps/W1/External File Storage - SFTP Connector/App/Entitlements/ExtSFTPConnector.Entitlement.al new file mode 100644 index 0000000000..0c213b4471 --- /dev/null +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/Entitlements/ExtSFTPConnector.Entitlement.al @@ -0,0 +1,13 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace System.ExternalFileStorage; + +entitlement "Ext. SFTP Connector" +{ + + ObjectEntitlements = "Ext. SFTP - Edit"; + Type = Implicit; +} \ No newline at end of file diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/ExtensionLogo.png b/src/Apps/W1/External File Storage - SFTP Connector/App/ExtensionLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..30941b354fa335cad3ea5426ac24cadb2ee328e5 GIT binary patch literal 4681 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 literal 0 HcmV?d00001 diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/README.md b/src/Apps/W1/External File Storage - SFTP Connector/App/README.md new file mode 100644 index 0000000000..fcd9ecd03b --- /dev/null +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/README.md @@ -0,0 +1,2 @@ +# External File Storage - SFTP Connector +This connector allows access to SFTP Server Files and Folder. \ No newline at end of file diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/app.json b/src/Apps/W1/External File Storage - SFTP Connector/App/app.json new file mode 100644 index 0000000000..7156b0ed7c --- /dev/null +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/app.json @@ -0,0 +1,43 @@ +{ + "id": "e0df20ef-75a2-4fae-8e3a-88140ab29507", + "name": "External File Storage - SFTP Connector", + "publisher": "Microsoft", + "brief": "Enables file and folder operations for SFTP folders and files via the External File Storage Module with Business Central.", + "description": "This app enables file and folder operations for SFTP folders and files via the External File Storage Module with Business Central.", + "version": "28.0.0.0", + "privacyStatement": "https://go.microsoft.com/fwlink/?linkid=724009", + "EULA": "https://go.microsoft.com/fwlink/?linkid=2009120", + "help": "https://go.microsoft.com/fwlink/?linkid=2134520", + "url": "https://go.microsoft.com/fwlink/?linkid=724011", + "logo": "ExtensionLogo.png", + "application": "28.0.0.0", + "platform": "28.0.0.0", + "internalsVisibleTo": [ + { + "id": "87c3fa98-904d-452d-95fe-5de2c7f0b624", + "name": "External File Storage - SFTP Connector Tests", + "publisher": "Microsoft" + } + ], + "dependencies": [], + "screenshots": [], + "idRanges": [ + { + "from": 4590, + "to": 4599 + } + ], + "resourceExposurePolicy": { + "allowDebugging": true, + "allowDownloadingSource": true, + "includeSourceInSymbolFile": true + }, + "contextSensitiveHelpUrl": "https://go.microsoft.com/fwlink/?linkid=2134520", + "resourceFolders": [ + "data" + ], + "target": "Cloud", + "features": [ + "TranslationFile" + ] +} diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/data/connector-logo.png b/src/Apps/W1/External File Storage - SFTP Connector/App/data/connector-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..3e993a7494f5fc64219927e8aed6ec08323317c8 GIT binary patch literal 35449 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 +/// Displays an account that was registered via the SharePoint connector. +/// +page 4590 "Ext. SFTP Account" +{ + ApplicationArea = All; + Caption = 'SFTP Account'; + DataCaptionExpression = Rec.Name; + Extensible = false; + InsertAllowed = false; + PageType = Card; + Permissions = tabledata "Ext. SFTP Account" = rimd; + SourceTable = "Ext. SFTP Account"; + UsageCategory = None; + + layout + { + area(Content) + { + field(NameField; Rec.Name) + { + NotBlank = true; + ShowMandatory = true; + } + field(Hostname; Rec.Hostname) { } + field(Port; Rec.Port) { } + field(Fingerprints; Rec.Fingerprints) { } + field("Base Relative Folder Path"; Rec."Base Relative Folder Path") { } + field("Authentication Type"; Rec."Authentication Type") + { + trigger OnValidate() + begin + MaskSensitiveFields(); + UpdateAuthTypeVisibility(); + CurrPage.Update(true); + end; + } + field(Username; Rec.Username) { } + group(Credentials) + { + Caption = 'Credentials'; + Editable = PageEditable; + + group(SFTPPasswordGroup) + { + ShowCaption = false; + Visible = ClientSecretVisible; + + field(PasswordField; Passowrd) + { + Caption = 'Password'; + ExtendedDatatype = Masked; + ToolTip = 'Specifies the Password to access the SFTP Server.'; + trigger OnValidate() + begin + Rec.SetPassword(Passowrd); + end; + } + } + group(SFTPCertificateGroup) + { + ShowCaption = false; + Visible = CertificateVisible; + + field(CertificateUploadStatus; CertificateStatusText) + { + Caption = 'Certificate'; + Editable = false; + ToolTip = 'Specifies the certificate file used for authentication. Click here to upload a new certificate file (.pfx, .cer, or .crt).'; + + trigger OnDrillDown() + begin + Certificate := Rec.UploadCertificateFile(); + Rec.SetCertificate(Certificate); + UpdateCertificateStatus(); + end; + } + + field(CertificatePasswordField; CertificatePassword) + { + Caption = 'Certificate Password'; + ExtendedDatatype = Masked; + ToolTip = 'Specifies the password used to protect the private key in the certificate. Leave empty if the certificate is not password-protected.'; + + trigger OnValidate() + begin + Rec.SetCertificatePassword(CertificatePassword); + end; + } + } + } + field(Disabled; Rec.Disabled) { } + } + } + + var + PageEditable: Boolean; + ClientSecretVisible: Boolean; + CertificateVisible: Boolean; + [NonDebuggable] + Passowrd: Text; + Certificate: SecretText; + [NonDebuggable] + CertificatePassword: Text; + CertificateStatusText: Text; + + trigger OnOpenPage() + begin + Rec.SetCurrentKey(Name); + UpdateAuthTypeVisibility(); + end; + + trigger OnAfterGetCurrRecord() + begin + PageEditable := CurrPage.Editable(); + + MaskSensitiveFields(); + UpdateAuthTypeVisibility(); + UpdateCertificateStatus(); + end; + + local procedure MaskSensitiveFields() + begin + Clear(Passowrd); + Clear(Certificate); + Clear(CertificatePassword); + + if not IsNullGuid(Rec."Password Key") then + Passowrd := '***'; + + if not IsNullGuid(Rec."Certificate Password Key") then + CertificatePassword := '***'; + end; + + local procedure UpdateAuthTypeVisibility() + begin + ClientSecretVisible := Rec."Authentication Type" = Enum::"Ext. SFTP Auth Type"::Password; + CertificateVisible := Rec."Authentication Type" = Enum::"Ext. SFTP Auth Type"::Certificate; + + + if CertificateVisible then + UpdateCertificateStatus(); + end; + + local procedure UpdateCertificateStatus() + var + NoCertificateLbl: Label 'No certificate (click to upload)'; + CertificateUploadedLbl: Label 'Certificate uploaded (click to change)'; + begin + if IsNullGuid(Rec."Certificate Key") then + CertificateStatusText := NoCertificateLbl + else + CertificateStatusText := CertificateUploadedLbl; + end; +} \ No newline at end of file diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al new file mode 100644 index 0000000000..c15ed7b772 --- /dev/null +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al @@ -0,0 +1,209 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace System.ExternalFileStorage; + +using System.Text; +using System.Utilities; + +/// +/// Holds the information for all file accounts that are registered via the SFTP connector +/// +table 4590 "Ext. SFTP Account" +{ + Caption = 'SFTP Account'; + DataClassification = CustomerContent; + + fields + { + field(1; Id; Guid) + { + AllowInCustomizations = Never; + Caption = 'Primary Key'; + DataClassification = SystemMetadata; + } + field(2; Name; Text[250]) + { + Caption = 'Account Name'; + ToolTip = 'Specifies a descriptive name for this SharePoint storage account connection.'; + } + field(4; Hostname; Text[2048]) + { + Caption = 'Hostname'; + ToolTip = 'Specifies the hostname of the SFTP server.'; + } + field(5; Port; Integer) + { + Caption = 'Port'; + ToolTip = 'Specifies the port number of the SFTP server.'; + InitValue = 22; + } + field(6; "Base Relative Folder Path"; Text[2048]) + { + Caption = 'Base Relative Folder Path'; + ToolTip = 'Specifies the folder path relative to the site collection. Start with the document library or folder name (e.g., Shared Documents/Reports). This path can be copied from the URL of the folder in SharePoint after the site collection (e.g., /Shared Documents/Reports from https://mysharepoint.sharepoint.com/sites/ProjectX/Shared%20Documents/Reports).'; + } + field(7; Username; Text[256]) + { + Access = Internal; + Caption = 'Username'; + ToolTip = 'Specifies the username for authenticating with the SFTP server.'; + } + field(9; "Password Key"; Guid) + { + Access = Internal; + DataClassification = SystemMetadata; + } + field(10; Disabled; Boolean) + { + Caption = 'Disabled'; + ToolTip = 'Specifies if the account is disabled. Accounts are automatically disabled when a sandbox environment is created from production.'; + } + field(11; "Certificate Key"; Guid) + { + Access = Internal; + DataClassification = SystemMetadata; + } + field(12; "Certificate Password Key"; Guid) + { + Access = Internal; + DataClassification = SystemMetadata; + } + field(13; Fingerprints; Text[1024]) + { + Caption = 'Fingerprints'; + ToolTip = 'Specifies the known host fingerprints for this SFTP account. Each fingerprint must be prefixed with sha256: or md5:. Multiple fingerprints can be separated with commas.'; + Access = Internal; + DataClassification = SystemMetadata; + } + field(14; "Authentication Type"; Enum "Ext. SFTP Auth Type") + { + Caption = 'Authentication Type'; + ToolTip = 'Specifies the authentication method used for this SFTP account. Password uses username and password authentication. Certificate uses SSH key-based authentication.'; + InitValue = Password; + } + } + + keys + { + key(PK; Id) + { + Clustered = true; + } + } + + var + UnableToGetPasswordMsg: Label 'Unable to get SFTP Account Password.'; + UnableToSetPasswordMsg: Label 'Unable to set SFTP Password.'; + UnableToGetCertificateMsg: Label 'Unable to get SFTP Certificate.'; + UnableToSetCertificateMsg: Label 'Unable to set SFTP Certificate.'; + UnableToGetCertificatePasswordMsg: Label 'Unable to get SFTP Account Certificate Password.'; + UnableToSetCertificatePasswordMsg: Label 'Unable to set SFTP Certificate Password.'; + + trigger OnDelete() + begin + TryDeleteIsolatedStorageValue(Rec."Password Key"); + TryDeleteIsolatedStorageValue(Rec."Certificate Key"); + TryDeleteIsolatedStorageValue(Rec."Certificate Password Key"); + end; + +#pragma warning disable AS0022 + internal procedure SetPassword(Password: SecretText) + begin + if IsNullGuid(Rec."Password Key") then + Rec."Password Key" := CreateGuid(); + + SetIsolatedStorageValue(Rec."Password Key", Password, UnableToSetPasswordMsg); + end; + + internal procedure GetPassword(PasswordKey: Guid): SecretText + begin + exit(GetIsolatedStorageValue(PasswordKey, UnableToGetPasswordMsg)); + end; + + internal procedure SetCertificate(Certificate: SecretText) + begin + if IsNullGuid(Rec."Certificate Key") then + Rec."Certificate Key" := CreateGuid(); + + SetIsolatedStorageValue(Rec."Certificate Key", Certificate, UnableToSetCertificateMsg); + + // When setting certificate, clear client secret authentication + // as only one authentication method can be active + ClearClientSecretAuthentication(); + end; +#pragma warning restore AS0022 + + local procedure ClearClientSecretAuthentication() + begin + if not IsNullGuid(Rec."Password Key") then begin + TryDeleteIsolatedStorageValue(Rec."Password Key"); + Clear(Rec."Password Key"); + end; + end; + + internal procedure GetCertificate(CertificateKey: Guid) Result: InStream + var + Base64Convert: Codeunit "Base64 Convert"; + TempBlob: Codeunit "Temp Blob"; + CertificateBase64: Text; + Stream: OutStream; + begin + if not IsolatedStorage.Get(Format(CertificateKey), DataScope::Company, CertificateBase64) then + Error(UnableToGetCertificateMsg); + + TempBlob.CreateOutStream(Stream); + Base64Convert.FromBase64(CertificateBase64, Stream); + TempBlob.CreateInStream(Result); + end; + + internal procedure SetCertificatePassword(CertificatePassword: SecretText) + begin + if IsNullGuid(Rec."Certificate Password Key") then + Rec."Certificate Password Key" := CreateGuid(); + + SetIsolatedStorageValue(Rec."Certificate Password Key", CertificatePassword, UnableToSetCertificatePasswordMsg); + end; + + internal procedure GetCertificatePassword(CertificatePasswordKey: Guid): SecretText + begin + exit(GetIsolatedStorageValue(CertificatePasswordKey, UnableToGetCertificatePasswordMsg)); + end; + + local procedure TryDeleteIsolatedStorageValue(StorageKey: Guid) + begin + if not IsNullGuid(StorageKey) then + if IsolatedStorage.Delete(StorageKey) then; + end; + + local procedure SetIsolatedStorageValue(StorageKey: Guid; Value: SecretText; ErrorMessage: Text) + begin + if not IsolatedStorage.Set(Format(StorageKey), Value, DataScope::Company) then + Error(ErrorMessage); + end; + + local procedure GetIsolatedStorageValue(StorageKey: Guid; ErrorMessage: Text) Value: SecretText + begin + if not IsolatedStorage.Get(Format(StorageKey), DataScope::Company, Value) then + Error(ErrorMessage); + end; + + internal procedure UploadCertificateFile() CertificateBase64: SecretText + var + Base64Convert: Codeunit System.Text."Base64 Convert"; + UploadResult: Boolean; + InStr: InStream; + CertificateFilterTxt: Label 'Certificate Files (*.pfx;*.p12)|*.pfx;*.p12|All Files (*.*)|*.*'; + FileNotUploadedErr: Label 'Certificate file was not uploaded.'; + begin + UploadResult := UploadIntoStream(CertificateFilterTxt, InStr); + + if not UploadResult then + Error(FileNotUploadedErr); + + CertificateBase64 := Base64Convert.ToBase64(InStr); + exit(CertificateBase64); + end; +} \ No newline at end of file diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al new file mode 100644 index 0000000000..e4caef2661 --- /dev/null +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al @@ -0,0 +1,253 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace System.ExternalFileStorage; + +using System.Environment; + +/// +/// Displays an account that is being registered via the SFTP connector. +/// +page 4591 "Ext. SFTP Account Wizard" +{ + ApplicationArea = All; + Caption = 'Setup SFTP Account'; + Editable = true; + Extensible = false; + PageType = NavigatePage; + Permissions = tabledata "Ext. SFTP Account" = rimd; + SourceTable = "Ext. SFTP Account"; + SourceTableTemporary = true; + + layout + { + area(Content) + { + group(TopBanner) + { + Editable = false; + ShowCaption = false; + Visible = TopBannerVisible; + field(NotDoneIcon; MediaResources."Media Reference") + { + Editable = false; + ShowCaption = false; + ToolTip = ' ', Locked = true; + } + } + + field(NameField; Rec.Name) + { + Caption = 'Account Name'; + NotBlank = true; + ShowMandatory = true; + ToolTip = 'Specifies a descriptive name for this SharePoint storage account connection.'; + + trigger OnValidate() + begin + IsNextEnabled := SharePointConnectorImpl.IsAccountValid(Rec); + end; + } + field(Hostname; Rec.Hostname) + { + NotBlank = true; + ShowMandatory = true; + + trigger OnValidate() + begin + IsNextEnabled := SharePointConnectorImpl.IsAccountValid(Rec); + end; + } + field(Port; Rec.Port) + { + NotBlank = true; + ShowMandatory = true; + + trigger OnValidate() + begin + IsNextEnabled := SharePointConnectorImpl.IsAccountValid(Rec); + end; + } + field(Fingerprints; Rec.Fingerprints) + { + Caption = 'Fingerprints'; + ShowMandatory = true; + + trigger OnValidate() + begin + IsNextEnabled := SharePointConnectorImpl.IsAccountValid(Rec); + end; + } + field("Authentication Type"; Rec."Authentication Type") + { + ToolTip = 'Specifies the authentication flow used for this SharePoint account. Client Secret uses User grant flow, which means that the user must sign in when using this account. Certificate uses Client credentials flow, which means that the user does not need to sign in when using this account.'; + trigger OnValidate() + begin + UpdateAuthTypeVisibility(); + IsNextEnabled := SharePointConnectorImpl.IsAccountValid(Rec); + end; + } + field(Username; Rec.Username) + { + NotBlank = true; + ShowMandatory = true; + + trigger OnValidate() + begin + IsNextEnabled := SharePointConnectorImpl.IsAccountValid(Rec); + end; + } + group(PasswordGroup) + { + ShowCaption = false; + Visible = ClientSecretVisible; + + field(Password; ClientSecret) + { + Caption = 'Password'; + ExtendedDatatype = Masked; + ShowMandatory = true; + ToolTip = 'Specifies the Password to access the SFTP Server.'; + } + } + group(CertificateGroup) + { + ShowCaption = false; + Visible = CertificateVisible; + + field(CertificateUploadStatus; CertificateStatusText) + { + Caption = 'Certificate'; + Editable = false; + ShowMandatory = true; + ToolTip = 'Specifies the certificate file used for authentication. Click here to upload a certificate file (.pfx, .cer, or .crt).'; + + trigger OnDrillDown() + begin + Certificate := Rec.UploadCertificateFile(); + UpdateCertificateStatus(); + IsNextEnabled := SharePointConnectorImpl.IsAccountValid(Rec); + end; + } + + field(CertificatePasswordField; CertificatePassword) + { + Caption = 'Certificate Password'; + ExtendedDatatype = Masked; + ShowMandatory = false; + ToolTip = 'Specifies the password used to protect the private key in the certificate. Leave empty if the certificate is not password-protected.'; + } + } + + field("Base Relative Folder Path"; Rec."Base Relative Folder Path") + { + ShowMandatory = true; + + trigger OnValidate() + begin + IsNextEnabled := SharePointConnectorImpl.IsAccountValid(Rec); + end; + } + } + } + + actions + { + area(processing) + { + action(Back) + { + Caption = 'Back'; + Image = Cancel; + InFooterBar = true; + ToolTip = 'Move to previous step.'; + + trigger OnAction() + begin + CurrPage.Close(); + end; + } + action(Next) + { + Caption = 'Next'; + Enabled = IsNextEnabled; + Image = NextRecord; + InFooterBar = true; + ToolTip = 'Move to next step.'; + + trigger OnAction() + var + SecretToPass: SecretText; + begin + case Rec."Authentication Type" of + Enum::"Ext. SFTP Auth Type"::Password: + SecretToPass := ClientSecret; + Enum::"Ext. SFTP Auth Type"::Certificate: + SecretToPass := Certificate; + end; + + SharePointConnectorImpl.CreateAccount(Rec, SecretToPass, CertificatePassword, SharePointAccount); + CurrPage.Close(); + end; + } + } + } + + var + SharePointAccount: Record "File Account"; + MediaResources: Record "Media Resources"; + SharePointConnectorImpl: Codeunit "Ext. SFTP Connector Impl"; + [NonDebuggable] + ClientSecret, CertificatePassword : Text; + CertificateStatusText: Text; + Certificate: SecretText; + IsNextEnabled: Boolean; + TopBannerVisible: Boolean; + ClientSecretVisible, CertificateVisible : Boolean; + + trigger OnOpenPage() + var + AssistedSetupLogoTok: Label 'ASSISTEDSETUP-NOTEXT-400PX.PNG', Locked = true; + begin + Rec.Init(); + Rec.Insert(); + + if MediaResources.Get(AssistedSetupLogoTok) and (CurrentClientType() = ClientType::Web) then + TopBannerVisible := MediaResources."Media Reference".HasValue(); + + UpdateAuthTypeVisibility(); + UpdateCertificateStatus(); + end; + + internal procedure GetAccount(var FileAccount: Record "File Account"): Boolean + begin + if IsNullGuid(SharePointAccount."Account Id") then + exit(false); + + FileAccount := SharePointAccount; + + exit(true); + end; + + local procedure UpdateAuthTypeVisibility() + begin + ClientSecretVisible := Rec."Authentication Type" = Enum::"Ext. SFTP Auth Type"::Password; + CertificateVisible := Rec."Authentication Type" = Enum::"Ext. SFTP Auth Type"::Certificate; + + if CertificateVisible then + UpdateCertificateStatus(); + end; + + local procedure UpdateCertificateStatus() + var + NoCertificateUploadedLbl: Label 'Click to upload certificate file...'; + CertificateUploadedLbl: Label 'Certificate uploaded (click to change)'; + begin + if Certificate.IsEmpty() then + CertificateStatusText := NoCertificateUploadedLbl + else + CertificateStatusText := CertificateUploadedLbl; + end; +} \ No newline at end of file diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAuthType.Enum.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAuthType.Enum.al new file mode 100644 index 0000000000..57184b24da --- /dev/null +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAuthType.Enum.al @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace System.ExternalFileStorage; + +/// +/// Specifies the authentication types for SFTP accounts. +/// +enum 4595 "Ext. SFTP Auth Type" +{ + Extensible = false; + Access = Public; + + /// + /// Authenticate password. + /// + value(0; Password) + { + Caption = 'Password'; + } + + /// + /// Authenticate using private key. + /// + value(1; Certificate) + { + Caption = 'Certificate'; + } +} diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnector.EnumExt.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnector.EnumExt.al new file mode 100644 index 0000000000..52d3d43f88 --- /dev/null +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnector.EnumExt.al @@ -0,0 +1,21 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace System.ExternalFileStorage; + +/// +/// Enum extension to register the SharePoint connector. +/// +enumextension 4590 "Ext. SFTP Connector" extends "Ext. File Storage Connector" +{ + /// + /// The SharePoint connector. + /// + value(4590; "SFTP") + { + Caption = 'SFTP'; + Implementation = "External File Storage Connector" = "Ext. SFTP Connector Impl"; + } +} \ No newline at end of file diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al new file mode 100644 index 0000000000..b707acae2e --- /dev/null +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al @@ -0,0 +1,483 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace System.ExternalFileStorage; + +using System.DataAdministration; +using System.SFTPClient; +using System.Text; +using System.Utilities; + +codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Connector" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + Permissions = tabledata "Ext. SFTP Account" = rimd; + + var + ConnectorDescriptionTxt: Label 'Use SharePoint to store and retrieve files.', MaxLength = 250; + NotRegisteredAccountErr: Label 'We could not find the account. Typically, this is because the account has been deleted.'; + + /// + /// Gets a List of Files stored on the provided account. + /// + /// The file account ID which is used to get the file. + /// The file path to list. + /// Defines the pagination data. + /// A list with all files stored in the path. + procedure ListFiles(AccountId: Guid; Path: Text; FilePaginationData: Codeunit "File Pagination Data"; var TempFileAccountContent: Record "File Account Content" temporary) + var + FileList: Record "SFTP Folder Content"; + SFTPClient: Codeunit "SFTP Client"; + Response: Codeunit "SFTP Operation Response"; + OrginalPath: Text; + begin + OrginalPath := Path; + InitPath(AccountId, Path); + InitSFTPClient(AccountId, SFTPClient); + Response := SFTPClient.ListFiles(Path, FileList); + + if Response.IsError() then + ShowError(Response); + + FilePaginationData.SetEndOfListing(true); + + FileList.SetRange("Is Directory", false); + if not FileList.FindSet() then + exit; + + repeat + TempFileAccountContent.Init(); + TempFileAccountContent.Name := FileList.Name; + TempFileAccountContent.Type := TempFileAccountContent.Type::"File"; + TempFileAccountContent."Parent Directory" := CopyStr(OrginalPath, 1, MaxStrLen(TempFileAccountContent."Parent Directory")); + TempFileAccountContent.Insert(); + until FileList.Next() = 0; + end; + + /// + /// Gets a file from the provided account. + /// + /// The file account ID which is used to get the file. + /// The file path inside the file account. + /// The Stream were the file is read to. + procedure GetFile(AccountId: Guid; Path: Text; Stream: InStream) + var + SFTPClient: Codeunit "SFTP Client"; + Response: Codeunit "SFTP Operation Response"; + Content: HttpContent; + TempBlobStream: InStream; + begin + InitPath(AccountId, Path); + InitSFTPClient(AccountId, SFTPClient); + + SFTPClient.GetFileAsStream(Path, TempBlobStream); + + // Platform fix: For some reason the Stream from DownloadFileContentByServerRelativeUrl dies after leaving the interface + Content.WriteFrom(TempBlobStream); + Content.ReadAs(Stream); + + if Response.IsError() then + ShowError(Response); + end; + + /// + /// Create a file in the provided account. + /// + /// The file account ID which is used to send out the file. + /// The file path inside the file account. + /// The Stream were the file is read from. + procedure CreateFile(AccountId: Guid; Path: Text; Stream: InStream) + var + SFTPClient: Codeunit "SFTP Client"; + Response: Codeunit "SFTP Operation Response"; + ParentPath, FileName : Text; + begin + InitPath(AccountId, Path); + InitSFTPClient(AccountId, SFTPClient); + SplitPath(Path, ParentPath, FileName); + + Response := SFTPClient.PutFileStream(Path, Stream); + + if Response.IsError() then + ShowError(Response); + end; + + /// + /// Copies as file inside the provided account. + /// + /// The file account ID which is used to send out the file. + /// The source file path. + /// The target file path. + procedure CopyFile(AccountId: Guid; SourcePath: Text; TargetPath: Text) + var + TempBlob: Codeunit "Temp Blob"; + Stream: InStream; + begin + TempBlob.CreateInStream(Stream); + + GetFile(AccountId, SourcePath, Stream); + CreateFile(AccountId, TargetPath, Stream); + end; + + /// + /// Move as file inside the provided account. + /// + /// The file account ID which is used to send out the file. + /// The source file path. + /// The target file path. + procedure MoveFile(AccountId: Guid; SourcePath: Text; TargetPath: Text) + var + SFTPClient: Codeunit "SFTP Client"; + Response: Codeunit "SFTP Operation Response"; + begin + InitSFTPClient(AccountId, SFTPClient); + InitPath(AccountId, SourcePath); + InitPath(AccountId, TargetPath); + + Response := SFTPClient.MoveFile(SourcePath, TargetPath); + if Response.IsError() then + ShowError(Response); + end; + + /// + /// Checks if a file exists on the provided account. + /// + /// The file account ID which is used to send out the file. + /// The file path inside the file account. + /// Returns true if the file exists + procedure FileExists(AccountId: Guid; Path: Text) Result: Boolean + var + SFTPClient: Codeunit "SFTP Client"; + Response: Codeunit "SFTP Operation Response"; + begin + InitPath(AccountId, Path); + InitSFTPClient(AccountId, SFTPClient); + + Response := SFTPClient.FileExists(Path, Result); + + if Response.IsError() then + ShowError(Response); + end; + + /// + /// Deletes a file exists on the provided account. + /// + /// The file account ID which is used to send out the file. + /// The file path inside the file account. + procedure DeleteFile(AccountId: Guid; Path: Text) + var + SFTPClient: Codeunit "SFTP Client"; + Response: Codeunit "SFTP Operation Response"; + begin + InitPath(AccountId, Path); + InitSFTPClient(AccountId, SFTPClient); + + Response := SFTPClient.DeleteFile(Path); + + if Response.IsError() then + ShowError(Response); + end; + + /// + /// Gets a List of Directories stored on the provided account. + /// + /// The file account ID which is used to get the file. + /// The file path to list. + /// Defines the pagination data. + /// A list with all directories stored in the path. + procedure ListDirectories(AccountId: Guid; Path: Text; FilePaginationData: Codeunit "File Pagination Data"; var TempFileAccountContent: Record "File Account Content" temporary) + var + FileList: Record "SFTP Folder Content"; + SFTPClient: Codeunit "SFTP Client"; + Response: Codeunit "SFTP Operation Response"; + OrginalPath: Text; + begin + OrginalPath := Path; + InitPath(AccountId, Path); + InitSFTPClient(AccountId, SFTPClient); + Response := SFTPClient.ListFiles(Path, FileList); + + if Response.IsError() then + ShowError(Response); + + FilePaginationData.SetEndOfListing(true); + + FileList.SetRange("Is Directory", true); + FileList.SetFilter(Name, '<>%1&<>%2', '.', '..'); // Exclude . and .. + if not FileList.FindSet() then + exit; + + repeat + TempFileAccountContent.Init(); + TempFileAccountContent.Name := FileList.Name; + TempFileAccountContent.Type := TempFileAccountContent.Type::Directory; + TempFileAccountContent."Parent Directory" := CopyStr(OrginalPath, 1, MaxStrLen(TempFileAccountContent."Parent Directory")); + TempFileAccountContent.Insert(); + until FileList.Next() = 0; + end; + + /// + /// Creates a directory on the provided account. + /// + /// The file account ID which is used to send out the file. + /// The directory path inside the file account. + procedure CreateDirectory(AccountId: Guid; Path: Text) + var + SFTPClient: Codeunit "SFTP Client"; + Response: Codeunit "SFTP Operation Response"; + begin + InitPath(AccountId, Path); + InitSFTPClient(AccountId, SFTPClient); + Response := SFTPClient.CreateDirectory(Path); + + if Response.IsError() then + ShowError(Response); + end; + + /// + /// Checks if a directory exists on the provided account. + /// + /// The file account ID which is used to send out the file. + /// The directory path inside the file account. + /// Returns true if the directory exists + procedure DirectoryExists(AccountId: Guid; Path: Text) Result: Boolean + begin + exit(FileExists(AccountId, Path)); + end; + + /// + /// Deletes a directory exists on the provided account. + /// + /// The file account ID which is used to send out the file. + /// The directory path inside the file account. + procedure DeleteDirectory(AccountId: Guid; Path: Text) + begin + DeleteFile(AccountId, Path); + end; + + /// + /// Gets the registered accounts for the SharePoint connector. + /// + /// Out parameter holding all the registered accounts for the SharePoint connector. + procedure GetAccounts(var TempAccounts: Record "File Account" temporary) + var + Account: Record "Ext. SFTP Account"; + begin + if not Account.FindSet() then + exit; + + repeat + TempAccounts."Account Id" := Account.Id; + TempAccounts.Name := Account.Name; + TempAccounts.Connector := Enum::"Ext. File Storage Connector"::"SFTP"; + TempAccounts.Insert(); + until Account.Next() = 0; + end; + + /// + /// Shows accounts information. + /// + /// The ID of the account to show. + procedure ShowAccountInformation(AccountId: Guid) + var + SharePointAccountLocal: Record "Ext. SFTP Account"; + begin + if not SharePointAccountLocal.Get(AccountId) then + Error(NotRegisteredAccountErr); + + SharePointAccountLocal.SetRecFilter(); + Page.Run(Page::"Ext. SFTP Account", SharePointAccountLocal); + end; + + /// + /// Register an file account for the SharePoint connector. + /// + /// Out parameter holding details of the registered account. + /// True if the registration was successful; false - otherwise. + procedure RegisterAccount(var TempAccount: Record "File Account" temporary): Boolean + var + SharePointAccountWizard: Page "Ext. SFTP Account Wizard"; + begin + SharePointAccountWizard.RunModal(); + + exit(SharePointAccountWizard.GetAccount(TempAccount)); + end; + + /// + /// Deletes an file account for the SharePoint connector. + /// + /// The ID of the SharePoint account + /// True if an account was deleted. + procedure DeleteAccount(AccountId: Guid): Boolean + var + SharePointAccountLocal: Record "Ext. SFTP Account"; + begin + if SharePointAccountLocal.Get(AccountId) then + exit(SharePointAccountLocal.Delete()); + + exit(false); + end; + + /// + /// Gets a description of the SharePoint connector. + /// + /// A short description of the SharePoint connector. + procedure GetDescription(): Text[250] + begin + exit(ConnectorDescriptionTxt); + end; + + /// + /// Gets the SharePoint connector logo. + /// + /// A base64-formatted image to be used as logo. + procedure GetLogoAsBase64(): Text + var + Base64Convert: Codeunit "Base64 Convert"; + Stream: InStream; + begin + NavApp.GetResource('connector-logo.png', Stream); + exit(Base64Convert.ToBase64(Stream)); + end; + + internal procedure IsAccountValid(var TempAccount: Record "Ext. SFTP Account" temporary): Boolean + begin + if TempAccount.Name = '' then + exit(false); + + if TempAccount."Hostname" = '' then + exit(false); + + if TempAccount."Base Relative Folder Path" = '' then + exit(false); + + exit(true); + end; + + internal procedure CreateAccount(var AccountToCopy: Record "Ext. SFTP Account"; ClientSecretOrCertificate: SecretText; CertificatePassword: SecretText; var TempFileAccount: Record "File Account" temporary) + var + NewExtSharePointAccount: Record "Ext. SFTP Account"; + begin + NewExtSharePointAccount.TransferFields(AccountToCopy); + NewExtSharePointAccount.Id := CreateGuid(); + + NewExtSharePointAccount.SetPassword(ClientSecretOrCertificate); + + NewExtSharePointAccount.Insert(); + + TempFileAccount."Account Id" := NewExtSharePointAccount.Id; + TempFileAccount.Name := NewExtSharePointAccount.Name; + TempFileAccount.Connector := Enum::"Ext. File Storage Connector"::"SFTP"; + end; + + local procedure InitSFTPClient(var AccountId: Guid; var SFTPClient: Codeunit "SFTP Client") + var + SFTPAccount: Record "Ext. SFTP Account"; + AccountDisabledErr: Label 'The account "%1" is disabled.', Comment = '%1 - Account Name'; + begin + SFTPAccount.Get(AccountId); + if SFTPAccount.Disabled then + Error(AccountDisabledErr, SFTPAccount.Name); + + AddFingerPrints(SFTPAccount."Fingerprints", SFTPClient); + + case SFTPAccount."Authentication Type" of + Enum::"Ext. SFTP Auth Type"::Password: + SFTPClient.Initialize(SFTPAccount.Hostname, SFTPAccount.Port, SFTPAccount.Username, SFTPAccount.GetPassword(SFTPAccount."Password Key")); + Enum::"Ext. SFTP Auth Type"::Certificate: + if IsNullGuid(SFTPAccount."Certificate Key") then + SFTPClient.Initialize(SFTPAccount.Hostname, SFTPAccount.Port, SFTPAccount.Username, SFTPAccount.GetCertificate(SFTPAccount."Certificate Key")) + else + SFTPClient.Initialize(SFTPAccount.Hostname, SFTPAccount.Port, SFTPAccount.Username, SFTPAccount.GetCertificate(SFTPAccount."Certificate Key"), SFTPAccount.GetCertificatePassword(SFTPAccount."Certificate Password Key")); + end; + end; + + local procedure PathSeparator(): Text + begin + exit('/'); + end; + + local procedure ShowError(var Response: Codeunit "SFTP Operation Response") + var + ErrorOccuredErr: Label 'An error occured.\%1', Comment = '%1 - Error message from sharepoint'; + begin + Error(ErrorOccuredErr, Response.GetError()); + end; + + local procedure GetParentPath(Path: Text) ParentPath: Text + begin + if (Path.TrimEnd(PathSeparator()).Contains(PathSeparator())) then + ParentPath := Path.TrimEnd(PathSeparator()).Substring(1, Path.LastIndexOf(PathSeparator())); + end; + + local procedure GetFileName(Path: Text) FileName: Text + begin + if (Path.TrimEnd(PathSeparator()).Contains(PathSeparator())) then + FileName := Path.TrimEnd(PathSeparator()).Substring(Path.LastIndexOf(PathSeparator()) + 1); + end; + + local procedure InitPath(AccountId: Guid; var Path: Text) + var + SFTPAccount: Record "Ext. SFTP Account"; + begin + SFTPAccount.Get(AccountId); + Path := CombinePath(SFTPAccount."Base Relative Folder Path", Path); + end; + + local procedure CombinePath(Parent: Text; Child: Text): Text + begin + if Parent = '' then + exit(Child); + + if Child = '' then + exit(Parent); + + if not Parent.EndsWith(PathSeparator()) then + Parent += PathSeparator(); + + exit(Parent + Child); + end; + + local procedure SplitPath(Path: Text; var ParentPath: Text; var FileName: Text) + begin + ParentPath := Path.TrimEnd(PathSeparator()).Substring(1, Path.LastIndexOf(PathSeparator())); + FileName := Path.TrimEnd(PathSeparator()).Substring(Path.LastIndexOf(PathSeparator()) + 1); + end; + + local procedure AddFingerprints(Fingerprints: Text[1024]; var SFTPClient: Codeunit "SFTP Client") + var + Fingerprint: Text; + begin + foreach Fingerprint in Fingerprints.Split(',') do + AddFingerprint(Fingerprint, SFTPClient); + end; + + local procedure AddFingerprint(Fingerprint: Text; var SFTPClient: Codeunit "SFTP Client") + var + SHA256PrefixTok: Label 'sha256:', Locked = true; + MD5PrefixTok: Label 'md5:', Locked = true; + begin + Fingerprint := Fingerprint.Trim(); + if Fingerprint.StartsWith(SHA256PrefixTok) then + SFTPClient.AddFingerprintSHA256(Fingerprint.Substring(StrLen(SHA256PrefixTok) + 1)) + else + if Fingerprint.StartsWith(MD5PrefixTok) then + SFTPClient.AddFingerprintMD5(Fingerprint.Substring(StrLen(MD5PrefixTok) + 1)); + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Environment Cleanup", OnClearCompanyConfig, '', false, false)] + local procedure EnvironmentCleanup_OnClearCompanyConfig(CompanyName: Text; SourceEnv: Enum "Environment Type"; DestinationEnv: Enum "Environment Type") + var + ExtSharePointAccount: Record "Ext. SFTP Account"; + begin + ExtSharePointAccount.SetRange(Disabled, false); + if ExtSharePointAccount.IsEmpty() then + exit; + + ExtSharePointAccount.ModifyAll(Disabled, true); + end; +} \ No newline at end of file diff --git a/src/Apps/W1/External File Storage - SFTP Connector/Test/ExtensionLogo.png b/src/Apps/W1/External File Storage - SFTP Connector/Test/ExtensionLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..30941b354fa335cad3ea5426ac24cadb2ee328e5 GIT binary patch literal 4681 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 literal 0 HcmV?d00001 diff --git a/src/Apps/W1/External File Storage - SFTP Connector/Test/README.md b/src/Apps/W1/External File Storage - SFTP Connector/Test/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Apps/W1/External File Storage - SFTP Connector/Test/app.json b/src/Apps/W1/External File Storage - SFTP Connector/Test/app.json new file mode 100644 index 0000000000..8cc2b030ae --- /dev/null +++ b/src/Apps/W1/External File Storage - SFTP Connector/Test/app.json @@ -0,0 +1,58 @@ +{ + "id": "87c3fa98-904d-452d-95fe-5de2c7f0b624", + "name": "External File Storage - SFTP Connector Tests", + "publisher": "Microsoft", + "brief": "Tests for the External File Storage - SFTP Connector app", + "description": "Tests for the External File Storage - SFTP Connector app", + "version": "28.0.0.0", + "privacyStatement": "https://go.microsoft.com/fwlink/?linkid=724009", + "EULA": "https://go.microsoft.com/fwlink/?linkid=2009120", + "help": "https://go.microsoft.com/fwlink/?linkid=2134520", + "url": "https://go.microsoft.com/fwlink/?linkid=724011", + "logo": "ExtensionLogo.png", + "application": "28.0.0.0", + "dependencies": [ + { + "id": "e0df20ef-75a2-4fae-8e3a-88140ab29507", + "name": "External File Storage - SFTP Connector", + "publisher": "Microsoft", + "version": "28.0.0.0" + }, + { + "id": "dd0be2ea-f733-4d65-bb34-a28f4624fb14", + "name": "Library Assert", + "publisher": "Microsoft", + "version": "28.0.0.0" + }, + { + "id": "e7320ebb-08b3-4406-b1ec-b4927d3e280b", + "name": "Any", + "publisher": "Microsoft", + "version": "28.0.0.0" + }, + { + "id": "9856ae4f-d1a7-46ef-89bb-6ef056398228", + "name": "System Application Test Library", + "publisher": "Microsoft", + "version": "28.0.0.0" + } + ], + "screenshots": [], + "platform": "28.0.0.0", + "idRanges": [ + { + "from": 100000, + "to": 150000 + } + ], + "target": "OnPrem", + "resourceExposurePolicy": { + "allowDebugging": true, + "allowDownloadingSource": true, + "includeSourceInSymbolFile": true + }, + "contextSensitiveHelpUrl": "https://go.microsoft.com/fwlink/?linkid=2134520", + "features": [ + "TranslationFile" + ] +} diff --git a/src/Apps/W1/External File Storage - SFTP Connector/Test/src/ExtSFTPConnectorTest.Codeunit.al b/src/Apps/W1/External File Storage - SFTP Connector/Test/src/ExtSFTPConnectorTest.Codeunit.al new file mode 100644 index 0000000000..01c0070409 --- /dev/null +++ b/src/Apps/W1/External File Storage - SFTP Connector/Test/src/ExtSFTPConnectorTest.Codeunit.al @@ -0,0 +1,162 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace System.Test.ExternalFileStorage; + +using System.Environment; +using System.ExternalFileStorage; +using System.TestLibraries.Utilities; + +codeunit 144591 "Ext. SFTP Connector Test" +{ + Subtype = Test; + TestPermissions = Disabled; + + [Test] + [Scope('OnPrem')] + [HandlerFunctions('AccountRegisterPageHandler')] + [TransactionModel(TransactionModel::AutoRollback)] + procedure TestMultipleAccountsCanBeRegistered() + var + FileAccount: Record "File Account"; + ExtFileConnector: Codeunit "Ext. SFTP Connector Impl"; + FileAccounts: TestPage "File Accounts"; + AccountIds: array[3] of Guid; + AccountName: array[3] of Text[250]; + Index: Integer; + begin + // [Scenario] Create multiple accounts + Initialize(); + + // [When] Multiple accounts are registered + for Index := 1 to 3 do begin + SetBasicAccount(); + + Assert.IsTrue(ExtFileConnector.RegisterAccount(FileAccount), 'Failed to register account.'); + AccountIds[Index] := FileAccount."Account Id"; + AccountName[Index] := FileAccountMock.Name(); + + // [Then] Accounts are retrieved from the GetAccounts method + FileAccount.DeleteAll(); + ExtFileConnector.GetAccounts(FileAccount); + Assert.RecordCount(FileAccount, Index); + end; + + FileAccounts.OpenView(); + for Index := 1 to 3 do begin + FileAccounts.GoToKey(AccountIds[Index], Enum::"Ext. File Storage Connector"::SFTP); + Assert.AreEqual(AccountName[Index], FileAccounts.NameField.Value(), 'A different name was expected.'); + end; + end; + + [Test] + [Scope('OnPrem')] + [HandlerFunctions('AccountRegisterPageHandler')] + [TransactionModel(TransactionModel::AutoRollback)] + procedure TestEnviromentCleanupDisablesAccounts() + var + FileAccount: Record "File Account"; + ExtSFTPAccount: Record "Ext. SFTP Account"; + ExtFileConnector: Codeunit "Ext. SFTP Connector Impl"; + EnvironmentTriggers: Codeunit "Environment Triggers"; + AccountIds: array[3] of Guid; + Index: Integer; + begin + // [Scenario] Create multiple accounts + Initialize(); + + // [When] Multiple accounts are registered + for Index := 1 to 3 do begin + SetBasicAccount(); + + Assert.IsTrue(ExtFileConnector.RegisterAccount(FileAccount), 'Failed to register account.'); + AccountIds[Index] := FileAccount."Account Id"; + + // [Then] Accounts are retrieved from the GetAccounts method + FileAccount.DeleteAll(); + ExtFileConnector.GetAccounts(FileAccount); + Assert.RecordCount(FileAccount, Index); + end; + + ExtSFTPAccount.SetRange(Disabled, true); + Assert.IsTrue(ExtSFTPAccount.IsEmpty(), 'Accounts are already disabled.'); + + EnvironmentTriggers.OnAfterCopyEnvironmentPerCompany(0, Any.AlphabeticText(30), 1, Any.AlphabeticText(30)); + + Assert.IsFalse(ExtSFTPAccount.IsEmpty(), 'Accounts are not disabled.'); + end; + + [Test] + [Scope('OnPrem')] + [HandlerFunctions('AccountRegisterPageHandler,AccountShowPageHandler')] + [TransactionModel(TransactionModel::AutoRollback)] + procedure TestShowAccountInformation() + var + FileAccount: Record "File Account"; + FileConnector: Codeunit "Ext. SFTP Connector Impl"; + begin + // [Scenario] Account Information is displayed in the Account page. + + // [Given] An file account + Initialize(); + SetBasicAccount(); + FileConnector.RegisterAccount(FileAccount); + + // [When] The ShowAccountInformation method is invoked + FileConnector.ShowAccountInformation(FileAccount."Account Id"); + + // [Then] The account page opens and displays the information + // Verify in AccountModalPageHandler + end; + + local procedure Initialize() + var + ExtSFTPAccount: Record "Ext. SFTP Account"; + begin + ExtSFTPAccount.DeleteAll(); + end; + + local procedure SetBasicAccount() + begin + FileAccountMock.Name(CopyStr(Any.AlphanumericText(250), 1, 250)); + FileAccountMock.Hostname(CopyStr(Any.AlphanumericText(250), 1, 250)); + FileAccountMock.Username(CopyStr(Any.AlphanumericText(256), 1, 256)); + FileAccountMock.BaseRelativeFolderPath(CopyStr(Any.AlphanumericText(250), 1, 250)); + FileAccountMock.Port(Any.IntegerInRange(1, 65535)); + FileAccountMock.Fingerprints(CopyStr(Any.AlphanumericText(1024), 1, 1024)); + FileAccountMock.Password('testpassword'); + end; + + [ModalPageHandler] + procedure AccountRegisterPageHandler(var AccountWizard: TestPage "Ext. SFTP Account Wizard") + begin + // Setup account + AccountWizard.NameField.SetValue(FileAccountMock.Name()); + AccountWizard.Hostname.SetValue(FileAccountMock.Hostname()); + AccountWizard.Username.SetValue(FileAccountMock.Username()); + AccountWizard."Base Relative Folder Path".SetValue(FileAccountMock.BaseRelativeFolderPath()); + AccountWizard.Password.SetValue(FileAccountMock.Password()); + AccountWizard.Port.SetValue(FileAccountMock.Port()); + AccountWizard.Fingerprints.SetValue(FileAccountMock.Fingerprints()); + AccountWizard.Next.Invoke(); + end; + + [PageHandler] + procedure AccountShowPageHandler(var Account: TestPage "Ext. SFTP Account") + begin + // Verify the account + Assert.AreEqual(FileAccountMock.Name(), Account.NameField.Value(), 'A different name was expected.'); + Assert.AreEqual(FileAccountMock.Hostname(), Account.Hostname.Value(), 'A different hostname was expected.'); + Assert.AreEqual(FileAccountMock.Username(), Account.Username.Value(), 'A different username was expected.'); + Assert.AreEqual(Format(FileAccountMock.Port()), Account.Port.Value(), 'A different port was expected.'); + Assert.AreEqual(FileAccountMock.Fingerprints(), Account.Fingerprints.Value(), 'A different fingerprints was expected.'); + Assert.AreEqual(FileAccountMock.BaseRelativeFolderPath(), Account."Base Relative Folder Path".Value(), 'A different base relative folder path was expected.'); + end; + + var + Any: Codeunit Any; + Assert: Codeunit "Library Assert"; + FileAccountMock: Codeunit "Ext. SFTP Account Mock"; +} \ No newline at end of file diff --git a/src/Apps/W1/External File Storage - SFTP Connector/Test/src/mocks/ExtSFTPAccountMock.Codeunit.al b/src/Apps/W1/External File Storage - SFTP Connector/Test/src/mocks/ExtSFTPAccountMock.Codeunit.al new file mode 100644 index 0000000000..f47e26ab56 --- /dev/null +++ b/src/Apps/W1/External File Storage - SFTP Connector/Test/src/mocks/ExtSFTPAccountMock.Codeunit.al @@ -0,0 +1,91 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace System.ExternalFileStorage; + +codeunit 144590 "Ext. SFTP Account Mock" +{ + Access = Internal; + SingleInstance = true; + + procedure Name(): Text[250] + begin + exit(AccName); + end; + + procedure Name(Value: Text[250]) + begin + AccName := Value; + end; + + procedure Hostname(): Text[250] + begin + exit(AccHostname); + end; + + procedure Hostname(Value: Text[250]) + begin + AccHostname := Value; + end; + + procedure Username(): Text[256] + begin + exit(AccUsername); + end; + + procedure Username(Value: Text[256]) + begin + AccUsername := Value; + end; + + procedure Fingerprints(): Text[1024] + begin + exit(AccFingerprints); + end; + + procedure Fingerprints(Value: Text[1024]) + begin + AccFingerprints := Value; + end; + + procedure Port(): Integer + begin + exit(AccPort); + end; + + procedure Port(Value: Integer) + begin + AccPort := Value; + end; + + procedure BaseRelativeFolderPath(): Text[250] + begin + exit(AccBaseRelativeFolderPath); + end; + + procedure BaseRelativeFolderPath(Value: Text[250]) + begin + AccBaseRelativeFolderPath := Value; + end; + + procedure Password(): Text + begin + exit(AccPassword); + end; + + procedure Password(Value: Text) + begin + AccPassword := Value; + end; + + var + AccName: Text[250]; + AccHostname: Text[250]; + AccUsername: Text[256]; + AccFingerprints: Text[1024]; + AccBaseRelativeFolderPath: Text[250]; + AccPassword: Text; + AccPort: Integer; +} \ No newline at end of file From 7e658dc3c5e3b959a986a5dcb9d29c18f02b0f04 Mon Sep 17 00:00:00 2001 From: Thomas Williamson Date: Tue, 23 Dec 2025 14:12:21 +0100 Subject: [PATCH 02/11] Remove unused local procedures for path handling in SFTP Connector implementation --- .../App/src/ExtSFTPConnectorImpl.Codeunit.al | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al index b707acae2e..09bb8cdfa2 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al @@ -408,18 +408,6 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne Error(ErrorOccuredErr, Response.GetError()); end; - local procedure GetParentPath(Path: Text) ParentPath: Text - begin - if (Path.TrimEnd(PathSeparator()).Contains(PathSeparator())) then - ParentPath := Path.TrimEnd(PathSeparator()).Substring(1, Path.LastIndexOf(PathSeparator())); - end; - - local procedure GetFileName(Path: Text) FileName: Text - begin - if (Path.TrimEnd(PathSeparator()).Contains(PathSeparator())) then - FileName := Path.TrimEnd(PathSeparator()).Substring(Path.LastIndexOf(PathSeparator()) + 1); - end; - local procedure InitPath(AccountId: Guid; var Path: Text) var SFTPAccount: Record "Ext. SFTP Account"; From 52e5c919ba28ec16f6162f847b6c5c05a54b8d06 Mon Sep 17 00:00:00 2001 From: Thomas Williamson Date: Tue, 23 Dec 2025 14:49:47 +0100 Subject: [PATCH 03/11] Update documentation and references from SharePoint to SFTP in connector implementation --- .../App/src/ExtSFTPAccount.Page.al | 2 +- .../App/src/ExtSFTPAccount.Table.al | 4 +- .../App/src/ExtSFTPAccountWizard.Page.al | 30 +++--- .../App/src/ExtSFTPConnector.EnumExt.al | 4 +- .../App/src/ExtSFTPConnectorImpl.Codeunit.al | 96 ++++++++++--------- 5 files changed, 72 insertions(+), 64 deletions(-) diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Page.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Page.al index e76d89cb5b..3da059a1d1 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Page.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Page.al @@ -6,7 +6,7 @@ namespace System.ExternalFileStorage; /// -/// Displays an account that was registered via the SharePoint connector. +/// Displays an account that was registered via the SFTP connector. /// page 4590 "Ext. SFTP Account" { diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al index c15ed7b772..97c2a14eda 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al @@ -27,7 +27,7 @@ table 4590 "Ext. SFTP Account" field(2; Name; Text[250]) { Caption = 'Account Name'; - ToolTip = 'Specifies a descriptive name for this SharePoint storage account connection.'; + ToolTip = 'Specifies a descriptive name for this SFTP storage account connection.'; } field(4; Hostname; Text[2048]) { @@ -43,7 +43,7 @@ table 4590 "Ext. SFTP Account" field(6; "Base Relative Folder Path"; Text[2048]) { Caption = 'Base Relative Folder Path'; - ToolTip = 'Specifies the folder path relative to the site collection. Start with the document library or folder name (e.g., Shared Documents/Reports). This path can be copied from the URL of the folder in SharePoint after the site collection (e.g., /Shared Documents/Reports from https://mysharepoint.sharepoint.com/sites/ProjectX/Shared%20Documents/Reports).'; + ToolTip = 'Specifies the base folder path on the SFTP server. Use an absolute path starting with / (e.g., /home/user/files or /data/uploads). All file operations will be relative to this path.'; } field(7; Username; Text[256]) { diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al index e4caef2661..b7780dc581 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al @@ -43,11 +43,11 @@ page 4591 "Ext. SFTP Account Wizard" Caption = 'Account Name'; NotBlank = true; ShowMandatory = true; - ToolTip = 'Specifies a descriptive name for this SharePoint storage account connection.'; + ToolTip = 'Specifies a descriptive name for this SFTP storage account connection.'; trigger OnValidate() begin - IsNextEnabled := SharePointConnectorImpl.IsAccountValid(Rec); + IsNextEnabled := ConnectorImpl.IsAccountValid(Rec); end; } field(Hostname; Rec.Hostname) @@ -57,7 +57,7 @@ page 4591 "Ext. SFTP Account Wizard" trigger OnValidate() begin - IsNextEnabled := SharePointConnectorImpl.IsAccountValid(Rec); + IsNextEnabled := ConnectorImpl.IsAccountValid(Rec); end; } field(Port; Rec.Port) @@ -67,7 +67,7 @@ page 4591 "Ext. SFTP Account Wizard" trigger OnValidate() begin - IsNextEnabled := SharePointConnectorImpl.IsAccountValid(Rec); + IsNextEnabled := ConnectorImpl.IsAccountValid(Rec); end; } field(Fingerprints; Rec.Fingerprints) @@ -77,16 +77,16 @@ page 4591 "Ext. SFTP Account Wizard" trigger OnValidate() begin - IsNextEnabled := SharePointConnectorImpl.IsAccountValid(Rec); + IsNextEnabled := ConnectorImpl.IsAccountValid(Rec); end; } field("Authentication Type"; Rec."Authentication Type") { - ToolTip = 'Specifies the authentication flow used for this SharePoint account. Client Secret uses User grant flow, which means that the user must sign in when using this account. Certificate uses Client credentials flow, which means that the user does not need to sign in when using this account.'; + ToolTip = 'Specifies the authentication method used for this SFTP account. Password uses username and password authentication. Certificate uses SSH key-based authentication.'; trigger OnValidate() begin UpdateAuthTypeVisibility(); - IsNextEnabled := SharePointConnectorImpl.IsAccountValid(Rec); + IsNextEnabled := ConnectorImpl.IsAccountValid(Rec); end; } field(Username; Rec.Username) @@ -96,7 +96,7 @@ page 4591 "Ext. SFTP Account Wizard" trigger OnValidate() begin - IsNextEnabled := SharePointConnectorImpl.IsAccountValid(Rec); + IsNextEnabled := ConnectorImpl.IsAccountValid(Rec); end; } group(PasswordGroup) @@ -128,7 +128,7 @@ page 4591 "Ext. SFTP Account Wizard" begin Certificate := Rec.UploadCertificateFile(); UpdateCertificateStatus(); - IsNextEnabled := SharePointConnectorImpl.IsAccountValid(Rec); + IsNextEnabled := ConnectorImpl.IsAccountValid(Rec); end; } @@ -147,7 +147,7 @@ page 4591 "Ext. SFTP Account Wizard" trigger OnValidate() begin - IsNextEnabled := SharePointConnectorImpl.IsAccountValid(Rec); + IsNextEnabled := ConnectorImpl.IsAccountValid(Rec); end; } } @@ -188,7 +188,7 @@ page 4591 "Ext. SFTP Account Wizard" SecretToPass := Certificate; end; - SharePointConnectorImpl.CreateAccount(Rec, SecretToPass, CertificatePassword, SharePointAccount); + ConnectorImpl.CreateAccount(Rec, SecretToPass, CertificatePassword, Account); CurrPage.Close(); end; } @@ -196,9 +196,9 @@ page 4591 "Ext. SFTP Account Wizard" } var - SharePointAccount: Record "File Account"; + Account: Record "File Account"; MediaResources: Record "Media Resources"; - SharePointConnectorImpl: Codeunit "Ext. SFTP Connector Impl"; + ConnectorImpl: Codeunit "Ext. SFTP Connector Impl"; [NonDebuggable] ClientSecret, CertificatePassword : Text; CertificateStatusText: Text; @@ -223,10 +223,10 @@ page 4591 "Ext. SFTP Account Wizard" internal procedure GetAccount(var FileAccount: Record "File Account"): Boolean begin - if IsNullGuid(SharePointAccount."Account Id") then + if IsNullGuid(Account."Account Id") then exit(false); - FileAccount := SharePointAccount; + FileAccount := Account; exit(true); end; diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnector.EnumExt.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnector.EnumExt.al index 52d3d43f88..fb5c00309d 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnector.EnumExt.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnector.EnumExt.al @@ -6,12 +6,12 @@ namespace System.ExternalFileStorage; /// -/// Enum extension to register the SharePoint connector. +/// Enum extension to register the SFTP connector. /// enumextension 4590 "Ext. SFTP Connector" extends "Ext. File Storage Connector" { /// - /// The SharePoint connector. + /// The SFTP connector. /// value(4590; "SFTP") { diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al index 09bb8cdfa2..3299fb424a 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al @@ -18,7 +18,7 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne Permissions = tabledata "Ext. SFTP Account" = rimd; var - ConnectorDescriptionTxt: Label 'Use SharePoint to store and retrieve files.', MaxLength = 250; + ConnectorDescriptionTxt: Label 'Use SFTP Server to store and retrieve files.', MaxLength = 250; NotRegisteredAccountErr: Label 'We could not find the account. Typically, this is because the account has been deleted.'; /// @@ -30,7 +30,7 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne /// A list with all files stored in the path. procedure ListFiles(AccountId: Guid; Path: Text; FilePaginationData: Codeunit "File Pagination Data"; var TempFileAccountContent: Record "File Account Content" temporary) var - FileList: Record "SFTP Folder Content"; + FolderContent: Record "SFTP Folder Content"; SFTPClient: Codeunit "SFTP Client"; Response: Codeunit "SFTP Operation Response"; OrginalPath: Text; @@ -38,24 +38,24 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne OrginalPath := Path; InitPath(AccountId, Path); InitSFTPClient(AccountId, SFTPClient); - Response := SFTPClient.ListFiles(Path, FileList); + Response := SFTPClient.ListFiles(Path, FolderContent); if Response.IsError() then ShowError(Response); FilePaginationData.SetEndOfListing(true); - FileList.SetRange("Is Directory", false); - if not FileList.FindSet() then + FolderContent.SetRange("Is Directory", false); + if not FolderContent.FindSet() then exit; repeat TempFileAccountContent.Init(); - TempFileAccountContent.Name := FileList.Name; + TempFileAccountContent.Name := FolderContent.Name; TempFileAccountContent.Type := TempFileAccountContent.Type::"File"; TempFileAccountContent."Parent Directory" := CopyStr(OrginalPath, 1, MaxStrLen(TempFileAccountContent."Parent Directory")); TempFileAccountContent.Insert(); - until FileList.Next() = 0; + until FolderContent.Next() = 0; end; /// @@ -75,6 +75,7 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne InitSFTPClient(AccountId, SFTPClient); SFTPClient.GetFileAsStream(Path, TempBlobStream); + SFTPClient.Disconnect(); // Platform fix: For some reason the Stream from DownloadFileContentByServerRelativeUrl dies after leaving the interface Content.WriteFrom(TempBlobStream); @@ -101,6 +102,7 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne SplitPath(Path, ParentPath, FileName); Response := SFTPClient.PutFileStream(Path, Stream); + SFTPClient.Disconnect(); if Response.IsError() then ShowError(Response); @@ -139,6 +141,8 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne InitPath(AccountId, TargetPath); Response := SFTPClient.MoveFile(SourcePath, TargetPath); + SFTPClient.Disconnect(); + if Response.IsError() then ShowError(Response); end; @@ -158,6 +162,7 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne InitSFTPClient(AccountId, SFTPClient); Response := SFTPClient.FileExists(Path, Result); + SFTPClient.Disconnect(); if Response.IsError() then ShowError(Response); @@ -177,6 +182,7 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne InitSFTPClient(AccountId, SFTPClient); Response := SFTPClient.DeleteFile(Path); + SFTPClient.Disconnect(); if Response.IsError() then ShowError(Response); @@ -191,7 +197,7 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne /// A list with all directories stored in the path. procedure ListDirectories(AccountId: Guid; Path: Text; FilePaginationData: Codeunit "File Pagination Data"; var TempFileAccountContent: Record "File Account Content" temporary) var - FileList: Record "SFTP Folder Content"; + FolderContent: Record "SFTP Folder Content"; SFTPClient: Codeunit "SFTP Client"; Response: Codeunit "SFTP Operation Response"; OrginalPath: Text; @@ -199,25 +205,26 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne OrginalPath := Path; InitPath(AccountId, Path); InitSFTPClient(AccountId, SFTPClient); - Response := SFTPClient.ListFiles(Path, FileList); + Response := SFTPClient.ListFiles(Path, FolderContent); + SFTPClient.Disconnect(); if Response.IsError() then ShowError(Response); FilePaginationData.SetEndOfListing(true); - FileList.SetRange("Is Directory", true); - FileList.SetFilter(Name, '<>%1&<>%2', '.', '..'); // Exclude . and .. - if not FileList.FindSet() then + FolderContent.SetRange("Is Directory", true); + FolderContent.SetFilter(Name, '<>%1&<>%2', '.', '..'); // Exclude . and .. + if not FolderContent.FindSet() then exit; repeat TempFileAccountContent.Init(); - TempFileAccountContent.Name := FileList.Name; + TempFileAccountContent.Name := FolderContent.Name; TempFileAccountContent.Type := TempFileAccountContent.Type::Directory; TempFileAccountContent."Parent Directory" := CopyStr(OrginalPath, 1, MaxStrLen(TempFileAccountContent."Parent Directory")); TempFileAccountContent.Insert(); - until FileList.Next() = 0; + until FolderContent.Next() = 0; end; /// @@ -233,6 +240,7 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne InitPath(AccountId, Path); InitSFTPClient(AccountId, SFTPClient); Response := SFTPClient.CreateDirectory(Path); + SFTPClient.Disconnect(); if Response.IsError() then ShowError(Response); @@ -260,9 +268,9 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne end; /// - /// Gets the registered accounts for the SharePoint connector. + /// Gets the registered accounts for the SFTP connector. /// - /// Out parameter holding all the registered accounts for the SharePoint connector. + /// Out parameter holding all the registered accounts for the SFTP connector. procedure GetAccounts(var TempAccounts: Record "File Account" temporary) var Account: Record "Ext. SFTP Account"; @@ -284,55 +292,55 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne /// The ID of the account to show. procedure ShowAccountInformation(AccountId: Guid) var - SharePointAccountLocal: Record "Ext. SFTP Account"; + AccountLocal: Record "Ext. SFTP Account"; begin - if not SharePointAccountLocal.Get(AccountId) then + if not AccountLocal.Get(AccountId) then Error(NotRegisteredAccountErr); - SharePointAccountLocal.SetRecFilter(); - Page.Run(Page::"Ext. SFTP Account", SharePointAccountLocal); + AccountLocal.SetRecFilter(); + Page.Run(Page::"Ext. SFTP Account", AccountLocal); end; /// - /// Register an file account for the SharePoint connector. + /// Register an file account for the SFTP connector. /// /// Out parameter holding details of the registered account. /// True if the registration was successful; false - otherwise. procedure RegisterAccount(var TempAccount: Record "File Account" temporary): Boolean var - SharePointAccountWizard: Page "Ext. SFTP Account Wizard"; + AccountWizard: Page "Ext. SFTP Account Wizard"; begin - SharePointAccountWizard.RunModal(); + AccountWizard.RunModal(); - exit(SharePointAccountWizard.GetAccount(TempAccount)); + exit(AccountWizard.GetAccount(TempAccount)); end; /// - /// Deletes an file account for the SharePoint connector. + /// Deletes an file account for the SFTP connector. /// - /// The ID of the SharePoint account + /// The ID of the SFTP account /// True if an account was deleted. procedure DeleteAccount(AccountId: Guid): Boolean var - SharePointAccountLocal: Record "Ext. SFTP Account"; + AccountLocal: Record "Ext. SFTP Account"; begin - if SharePointAccountLocal.Get(AccountId) then - exit(SharePointAccountLocal.Delete()); + if AccountLocal.Get(AccountId) then + exit(AccountLocal.Delete()); exit(false); end; /// - /// Gets a description of the SharePoint connector. + /// Gets a description of the SFTP connector. /// - /// A short description of the SharePoint connector. + /// A short description of the SFTP connector. procedure GetDescription(): Text[250] begin exit(ConnectorDescriptionTxt); end; /// - /// Gets the SharePoint connector logo. + /// Gets the SFTP connector logo. /// /// A base64-formatted image to be used as logo. procedure GetLogoAsBase64(): Text @@ -360,17 +368,17 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne internal procedure CreateAccount(var AccountToCopy: Record "Ext. SFTP Account"; ClientSecretOrCertificate: SecretText; CertificatePassword: SecretText; var TempFileAccount: Record "File Account" temporary) var - NewExtSharePointAccount: Record "Ext. SFTP Account"; + NewAccount: Record "Ext. SFTP Account"; begin - NewExtSharePointAccount.TransferFields(AccountToCopy); - NewExtSharePointAccount.Id := CreateGuid(); + NewAccount.TransferFields(AccountToCopy); + NewAccount.Id := CreateGuid(); - NewExtSharePointAccount.SetPassword(ClientSecretOrCertificate); + NewAccount.SetPassword(ClientSecretOrCertificate); - NewExtSharePointAccount.Insert(); + NewAccount.Insert(); - TempFileAccount."Account Id" := NewExtSharePointAccount.Id; - TempFileAccount.Name := NewExtSharePointAccount.Name; + TempFileAccount."Account Id" := NewAccount.Id; + TempFileAccount.Name := NewAccount.Name; TempFileAccount.Connector := Enum::"Ext. File Storage Connector"::"SFTP"; end; @@ -403,7 +411,7 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne local procedure ShowError(var Response: Codeunit "SFTP Operation Response") var - ErrorOccuredErr: Label 'An error occured.\%1', Comment = '%1 - Error message from sharepoint'; + ErrorOccuredErr: Label 'An error occured.\%1', Comment = '%1 - Error message from SFTP Server'; begin Error(ErrorOccuredErr, Response.GetError()); end; @@ -460,12 +468,12 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne [EventSubscriber(ObjectType::Codeunit, Codeunit::"Environment Cleanup", OnClearCompanyConfig, '', false, false)] local procedure EnvironmentCleanup_OnClearCompanyConfig(CompanyName: Text; SourceEnv: Enum "Environment Type"; DestinationEnv: Enum "Environment Type") var - ExtSharePointAccount: Record "Ext. SFTP Account"; + Account: Record "Ext. SFTP Account"; begin - ExtSharePointAccount.SetRange(Disabled, false); - if ExtSharePointAccount.IsEmpty() then + Account.SetRange(Disabled, false); + if Account.IsEmpty() then exit; - ExtSharePointAccount.ModifyAll(Disabled, true); + Account.ModifyAll(Disabled, true); end; } \ No newline at end of file From 0c15b11caaf1c71d7f30012a7c7457253f9f9a69 Mon Sep 17 00:00:00 2001 From: Thomas Williamson Date: Tue, 23 Dec 2025 14:52:18 +0100 Subject: [PATCH 04/11] Fix platform issue with Stream handling in GetFileAsStream and update AddFingerprints parameter type --- .../App/src/ExtSFTPConnectorImpl.Codeunit.al | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al index 3299fb424a..4235691873 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al @@ -77,7 +77,7 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne SFTPClient.GetFileAsStream(Path, TempBlobStream); SFTPClient.Disconnect(); - // Platform fix: For some reason the Stream from DownloadFileContentByServerRelativeUrl dies after leaving the interface + // Platform fix: For some reason the Stream from GetFileAsStream dies after leaving the interface Content.WriteFrom(TempBlobStream); Content.ReadAs(Stream); @@ -444,7 +444,7 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne FileName := Path.TrimEnd(PathSeparator()).Substring(Path.LastIndexOf(PathSeparator()) + 1); end; - local procedure AddFingerprints(Fingerprints: Text[1024]; var SFTPClient: Codeunit "SFTP Client") + local procedure AddFingerprints(Fingerprints: Text; var SFTPClient: Codeunit "SFTP Client") var Fingerprint: Text; begin From 8a43784f2ad60f9f9255e4e0fcd291b4a0ed4106 Mon Sep 17 00:00:00 2001 From: Thomas Williamson Date: Tue, 23 Dec 2025 16:06:22 +0100 Subject: [PATCH 05/11] Refactor authentication handling in SFTP account management to unify password visibility and improve clarity in authentication methods --- .../App/src/ExtSFTPAccount.Page.al | 6 ++-- .../App/src/ExtSFTPAccount.Table.al | 30 +++++++++++++++---- .../App/src/ExtSFTPAccountWizard.Page.al | 12 ++++---- .../App/src/ExtSFTPConnectorImpl.Codeunit.al | 14 +++++++-- 4 files changed, 45 insertions(+), 17 deletions(-) diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Page.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Page.al index 3da059a1d1..b6cbd01b78 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Page.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Page.al @@ -51,7 +51,7 @@ page 4590 "Ext. SFTP Account" group(SFTPPasswordGroup) { ShowCaption = false; - Visible = ClientSecretVisible; + Visible = PasswordVisible; field(PasswordField; Passowrd) { @@ -102,7 +102,7 @@ page 4590 "Ext. SFTP Account" var PageEditable: Boolean; - ClientSecretVisible: Boolean; + PasswordVisible: Boolean; CertificateVisible: Boolean; [NonDebuggable] Passowrd: Text; @@ -141,7 +141,7 @@ page 4590 "Ext. SFTP Account" local procedure UpdateAuthTypeVisibility() begin - ClientSecretVisible := Rec."Authentication Type" = Enum::"Ext. SFTP Auth Type"::Password; + PasswordVisible := Rec."Authentication Type" = Enum::"Ext. SFTP Auth Type"::Password; CertificateVisible := Rec."Authentication Type" = Enum::"Ext. SFTP Auth Type"::Certificate; diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al index 97c2a14eda..23aaba0112 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al @@ -116,6 +116,23 @@ table 4590 "Ext. SFTP Account" Rec."Password Key" := CreateGuid(); SetIsolatedStorageValue(Rec."Password Key", Password, UnableToSetPasswordMsg); + + // When setting password, clear certificate authentication + // as only one authentication method can be active + ClearCertificateAuthentication(); + end; + + local procedure ClearCertificateAuthentication() + begin + if not IsNullGuid(Rec."Certificate Key") then begin + TryDeleteIsolatedStorageValue(Rec."Certificate Key"); + Clear(Rec."Certificate Key"); + end; + + if not IsNullGuid(Rec."Certificate Password Key") then begin + TryDeleteIsolatedStorageValue(Rec."Certificate Password Key"); + Clear(Rec."Certificate Password Key"); + end; end; internal procedure GetPassword(PasswordKey: Guid): SecretText @@ -132,16 +149,17 @@ table 4590 "Ext. SFTP Account" // When setting certificate, clear client secret authentication // as only one authentication method can be active - ClearClientSecretAuthentication(); + ClearPasswordAuthentication(); end; #pragma warning restore AS0022 - local procedure ClearClientSecretAuthentication() + local procedure ClearPasswordAuthentication() begin - if not IsNullGuid(Rec."Password Key") then begin - TryDeleteIsolatedStorageValue(Rec."Password Key"); - Clear(Rec."Password Key"); - end; + if IsNullGuid(Rec."Password Key") then + exit; + + TryDeleteIsolatedStorageValue(Rec."Password Key"); + Clear(Rec."Password Key"); end; internal procedure GetCertificate(CertificateKey: Guid) Result: InStream diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al index b7780dc581..233a5db6d9 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al @@ -102,9 +102,9 @@ page 4591 "Ext. SFTP Account Wizard" group(PasswordGroup) { ShowCaption = false; - Visible = ClientSecretVisible; + Visible = PasswordVisible; - field(Password; ClientSecret) + field(Password; Password) { Caption = 'Password'; ExtendedDatatype = Masked; @@ -183,7 +183,7 @@ page 4591 "Ext. SFTP Account Wizard" begin case Rec."Authentication Type" of Enum::"Ext. SFTP Auth Type"::Password: - SecretToPass := ClientSecret; + SecretToPass := Password; Enum::"Ext. SFTP Auth Type"::Certificate: SecretToPass := Certificate; end; @@ -200,12 +200,12 @@ page 4591 "Ext. SFTP Account Wizard" MediaResources: Record "Media Resources"; ConnectorImpl: Codeunit "Ext. SFTP Connector Impl"; [NonDebuggable] - ClientSecret, CertificatePassword : Text; + Password, CertificatePassword : Text; CertificateStatusText: Text; Certificate: SecretText; IsNextEnabled: Boolean; TopBannerVisible: Boolean; - ClientSecretVisible, CertificateVisible : Boolean; + PasswordVisible, CertificateVisible : Boolean; trigger OnOpenPage() var @@ -233,7 +233,7 @@ page 4591 "Ext. SFTP Account Wizard" local procedure UpdateAuthTypeVisibility() begin - ClientSecretVisible := Rec."Authentication Type" = Enum::"Ext. SFTP Auth Type"::Password; + PasswordVisible := Rec."Authentication Type" = Enum::"Ext. SFTP Auth Type"::Password; CertificateVisible := Rec."Authentication Type" = Enum::"Ext. SFTP Auth Type"::Certificate; if CertificateVisible then diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al index 4235691873..15b0464229 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al @@ -366,14 +366,24 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne exit(true); end; - internal procedure CreateAccount(var AccountToCopy: Record "Ext. SFTP Account"; ClientSecretOrCertificate: SecretText; CertificatePassword: SecretText; var TempFileAccount: Record "File Account" temporary) + internal procedure CreateAccount(var AccountToCopy: Record "Ext. SFTP Account"; PasswordOrCertificate: SecretText; CertificatePassword: SecretText; var TempFileAccount: Record "File Account" temporary) var NewAccount: Record "Ext. SFTP Account"; begin NewAccount.TransferFields(AccountToCopy); NewAccount.Id := CreateGuid(); - NewAccount.SetPassword(ClientSecretOrCertificate); + NewAccount.SetPassword(PasswordOrCertificate); + + case NewAccount."Authentication Type" of + Enum::"Ext. SFTP Auth Type"::Password: + NewAccount.SetPassword(PasswordOrCertificate); + Enum::"Ext. SFTP Auth Type"::Certificate: + begin + NewAccount.SetCertificate(PasswordOrCertificate); + NewAccount.SetCertificatePassword(CertificatePassword); + end; + end; NewAccount.Insert(); From 753e4c52999b1db7d8795294da8e6680c2334fae Mon Sep 17 00:00:00 2001 From: Thomas Williamson Date: Tue, 23 Dec 2025 16:14:32 +0100 Subject: [PATCH 06/11] Remove unnecessary pragma warnings in SFTP Account table procedures --- .../App/src/ExtSFTPAccount.Table.al | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al index 23aaba0112..fa374b8296 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al @@ -109,7 +109,6 @@ table 4590 "Ext. SFTP Account" TryDeleteIsolatedStorageValue(Rec."Certificate Password Key"); end; -#pragma warning disable AS0022 internal procedure SetPassword(Password: SecretText) begin if IsNullGuid(Rec."Password Key") then @@ -151,7 +150,6 @@ table 4590 "Ext. SFTP Account" // as only one authentication method can be active ClearPasswordAuthentication(); end; -#pragma warning restore AS0022 local procedure ClearPasswordAuthentication() begin From 7ec63cc19cf2275cb668edb7a20403f18b83466f Mon Sep 17 00:00:00 2001 From: Thomas Williamson Date: Tue, 23 Dec 2025 16:25:50 +0100 Subject: [PATCH 07/11] Enhance SFTP account table with port validation and update SFTP connector implementation for improved path handling; add comprehensive test documentation for better coverage and clarity. --- .../App/src/ExtSFTPAccount.Table.al | 2 + .../App/src/ExtSFTPConnectorImpl.Codeunit.al | 18 +++--- .../Test/README.md | 56 +++++++++++++++++++ .../Test/app.json | 1 - 4 files changed, 65 insertions(+), 12 deletions(-) diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al index fa374b8296..a0b0ce1b94 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al @@ -39,6 +39,8 @@ table 4590 "Ext. SFTP Account" Caption = 'Port'; ToolTip = 'Specifies the port number of the SFTP server.'; InitValue = 22; + MinValue = 1; + MaxValue = 65535; } field(6; "Base Relative Folder Path"; Text[2048]) { diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al index 15b0464229..6f2b339199 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al @@ -20,6 +20,7 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne var ConnectorDescriptionTxt: Label 'Use SFTP Server to store and retrieve files.', MaxLength = 250; NotRegisteredAccountErr: Label 'We could not find the account. Typically, this is because the account has been deleted.'; + PathSeparatorTok: Label '/', Locked = true; /// /// Gets a List of Files stored on the provided account. @@ -39,6 +40,7 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne InitPath(AccountId, Path); InitSFTPClient(AccountId, SFTPClient); Response := SFTPClient.ListFiles(Path, FolderContent); + SFTPClient.Disconnect(); if Response.IsError() then ShowError(Response); @@ -414,11 +416,6 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne end; end; - local procedure PathSeparator(): Text - begin - exit('/'); - end; - local procedure ShowError(var Response: Codeunit "SFTP Operation Response") var ErrorOccuredErr: Label 'An error occured.\%1', Comment = '%1 - Error message from SFTP Server'; @@ -435,6 +432,8 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne end; local procedure CombinePath(Parent: Text; Child: Text): Text + var + JoinPathTok: Label '%1/%2', Locked = true; begin if Parent = '' then exit(Child); @@ -442,16 +441,13 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne if Child = '' then exit(Parent); - if not Parent.EndsWith(PathSeparator()) then - Parent += PathSeparator(); - - exit(Parent + Child); + exit(StrSubstNo(JoinPathTok, Parent.TrimEnd(PathSeparatorTok), Child.TrimStart(PathSeparatorTok))); end; local procedure SplitPath(Path: Text; var ParentPath: Text; var FileName: Text) begin - ParentPath := Path.TrimEnd(PathSeparator()).Substring(1, Path.LastIndexOf(PathSeparator())); - FileName := Path.TrimEnd(PathSeparator()).Substring(Path.LastIndexOf(PathSeparator()) + 1); + ParentPath := Path.TrimEnd(PathSeparatorTok).Substring(1, Path.LastIndexOf(PathSeparatorTok)); + FileName := Path.TrimEnd(PathSeparatorTok).Substring(Path.LastIndexOf(PathSeparatorTok) + 1); end; local procedure AddFingerprints(Fingerprints: Text; var SFTPClient: Codeunit "SFTP Client") diff --git a/src/Apps/W1/External File Storage - SFTP Connector/Test/README.md b/src/Apps/W1/External File Storage - SFTP Connector/Test/README.md index e69de29bb2..8dde636030 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/Test/README.md +++ b/src/Apps/W1/External File Storage - SFTP Connector/Test/README.md @@ -0,0 +1,56 @@ +# External File Storage - SFTP Connector Tests + +This extension contains automated tests for the External File Storage - SFTP Connector app. + +## Overview + +The test suite validates the functionality of the SFTP connector implementation for Business Central's External File Storage framework. It ensures that SFTP accounts can be properly registered, managed, and operated within the system. + +## Test Coverage + +### Account Management Tests + +- **TestMultipleAccountsCanBeRegistered**: Verifies that multiple SFTP accounts can be registered and retrieved correctly. Tests the ability to create up to 3 accounts and validate their persistence through the `GetAccounts` method. + +- **TestShowAccountInformation**: Validates that account information is correctly displayed in the account page, ensuring all fields (name, hostname, username, port, fingerprints, and base folder path) are properly rendered. + +### Environment Management Tests + +- **TestEnviromentCleanupDisablesAccounts**: Ensures that when an environment is copied, all SFTP accounts are automatically disabled as a security measure. This test verifies the `OnAfterCopyEnvironmentPerCompany` trigger functionality. + +## Test Structure + +### Source Files + +- **ExtSFTPConnectorTest.Codeunit.al**: Main test codeunit containing all test scenarios +- **mocks/ExtSFTPAccountMock.Codeunit.al**: Mock implementation for test data generation + +### Test Helpers + +The test suite includes several helper procedures: + +- `Initialize()`: Cleans up test data before each test +- `SetBasicAccount()`: Generates randomized test account data using the Any library +- `AccountRegisterPageHandler()`: Modal page handler for account registration +- `AccountShowPageHandler()`: Page handler for account information verification + +## Dependencies + +- **External File Storage - SFTP Connector**: The main application being tested +- **Library Assert**: Assertion library for test validation +- **Any**: Random test data generation library +- **System Application Test Library**: System-level test utilities + +## Running the Tests + +These tests are designed to run in a Business Central test environment with the following attributes: + +- Subtype: Test +- TestPermissions: Disabled +- TransactionModel: AutoRollback (for most tests) + +## Notes + +- All tests use randomized data to ensure independence and repeatability +- Tests follow the Given-When-Then pattern for clarity +- Transaction rollback ensures tests don't affect the database state diff --git a/src/Apps/W1/External File Storage - SFTP Connector/Test/app.json b/src/Apps/W1/External File Storage - SFTP Connector/Test/app.json index 8cc2b030ae..d230162129 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/Test/app.json +++ b/src/Apps/W1/External File Storage - SFTP Connector/Test/app.json @@ -45,7 +45,6 @@ "to": 150000 } ], - "target": "OnPrem", "resourceExposurePolicy": { "allowDebugging": true, "allowDownloadingSource": true, From 53b900c5041ff19c582a438b0508a8de9b69fa29 Mon Sep 17 00:00:00 2001 From: Thomas Williamson Date: Tue, 23 Dec 2025 17:24:02 +0100 Subject: [PATCH 08/11] Refactor SFTP account handling to unify password and certificate management; update procedures to use Text type for certificates and enhance account creation logic. --- .../App/src/ExtSFTPAccount.Page.al | 3 +-- .../App/src/ExtSFTPAccount.Table.al | 23 ++++++++++------ .../App/src/ExtSFTPAccountWizard.Page.al | 16 +++-------- .../App/src/ExtSFTPConnectorImpl.Codeunit.al | 27 ++++++++++++------- 4 files changed, 37 insertions(+), 32 deletions(-) diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Page.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Page.al index b6cbd01b78..64b9bde59f 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Page.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Page.al @@ -106,9 +106,8 @@ page 4590 "Ext. SFTP Account" CertificateVisible: Boolean; [NonDebuggable] Passowrd: Text; - Certificate: SecretText; [NonDebuggable] - CertificatePassword: Text; + CertificatePassword, Certificate : Text; CertificateStatusText: Text; trigger OnOpenPage() diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al index a0b0ce1b94..456005ef2f 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al @@ -141,12 +141,14 @@ table 4590 "Ext. SFTP Account" exit(GetIsolatedStorageValue(PasswordKey, UnableToGetPasswordMsg)); end; - internal procedure SetCertificate(Certificate: SecretText) + [NonDebuggable] + internal procedure SetCertificate(Certificate: Text) begin if IsNullGuid(Rec."Certificate Key") then Rec."Certificate Key" := CreateGuid(); - SetIsolatedStorageValue(Rec."Certificate Key", Certificate, UnableToSetCertificateMsg); + if not IsolatedStorage.Set(Format(Rec."Certificate Key"), Certificate, DataScope::Company) then + Error(UnableToSetCertificateMsg); // When setting certificate, clear client secret authentication // as only one authentication method can be active @@ -162,10 +164,10 @@ table 4590 "Ext. SFTP Account" Clear(Rec."Password Key"); end; - internal procedure GetCertificate(CertificateKey: Guid) Result: InStream + [NonDebuggable] + internal procedure GetCertificate(CertificateKey: Guid) TempBlob: Codeunit "Temp Blob" var Base64Convert: Codeunit "Base64 Convert"; - TempBlob: Codeunit "Temp Blob"; CertificateBase64: Text; Stream: OutStream; begin @@ -174,11 +176,16 @@ table 4590 "Ext. SFTP Account" TempBlob.CreateOutStream(Stream); Base64Convert.FromBase64(CertificateBase64, Stream); - TempBlob.CreateInStream(Result); end; internal procedure SetCertificatePassword(CertificatePassword: SecretText) begin + if CertificatePassword.IsEmpty() then begin + TryDeleteIsolatedStorageValue(Rec."Certificate Password Key"); + Clear(Rec."Certificate Password Key"); + exit; + end; + if IsNullGuid(Rec."Certificate Password Key") then Rec."Certificate Password Key" := CreateGuid(); @@ -208,13 +215,13 @@ table 4590 "Ext. SFTP Account" Error(ErrorMessage); end; - internal procedure UploadCertificateFile() CertificateBase64: SecretText + internal procedure UploadCertificateFile() CertificateBase64: Text var Base64Convert: Codeunit System.Text."Base64 Convert"; UploadResult: Boolean; InStr: InStream; - CertificateFilterTxt: Label 'Certificate Files (*.pfx;*.p12)|*.pfx;*.p12|All Files (*.*)|*.*'; - FileNotUploadedErr: Label 'Certificate file was not uploaded.'; + CertificateFilterTxt: Label 'Key Files (*.pk;*.ppk;*.pub)|*.pk;*.ppk;*.pub|All Files (*.*)|*.*'; + FileNotUploadedErr: Label 'Key file was not uploaded.'; begin UploadResult := UploadIntoStream(CertificateFilterTxt, InStr); diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al index 233a5db6d9..37f418f31a 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al @@ -178,17 +178,8 @@ page 4591 "Ext. SFTP Account Wizard" ToolTip = 'Move to next step.'; trigger OnAction() - var - SecretToPass: SecretText; begin - case Rec."Authentication Type" of - Enum::"Ext. SFTP Auth Type"::Password: - SecretToPass := Password; - Enum::"Ext. SFTP Auth Type"::Certificate: - SecretToPass := Certificate; - end; - - ConnectorImpl.CreateAccount(Rec, SecretToPass, CertificatePassword, Account); + ConnectorImpl.CreateAccount(Rec, Password, Certificate, CertificatePassword, Account); CurrPage.Close(); end; } @@ -200,9 +191,8 @@ page 4591 "Ext. SFTP Account Wizard" MediaResources: Record "Media Resources"; ConnectorImpl: Codeunit "Ext. SFTP Connector Impl"; [NonDebuggable] - Password, CertificatePassword : Text; + Password, CertificatePassword, Certificate : Text; CertificateStatusText: Text; - Certificate: SecretText; IsNextEnabled: Boolean; TopBannerVisible: Boolean; PasswordVisible, CertificateVisible : Boolean; @@ -245,7 +235,7 @@ page 4591 "Ext. SFTP Account Wizard" NoCertificateUploadedLbl: Label 'Click to upload certificate file...'; CertificateUploadedLbl: Label 'Certificate uploaded (click to change)'; begin - if Certificate.IsEmpty() then + if Certificate = '' then CertificateStatusText := NoCertificateUploadedLbl else CertificateStatusText := CertificateUploadedLbl; diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al index 6f2b339199..b8ee523b13 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al @@ -368,21 +368,21 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne exit(true); end; - internal procedure CreateAccount(var AccountToCopy: Record "Ext. SFTP Account"; PasswordOrCertificate: SecretText; CertificatePassword: SecretText; var TempFileAccount: Record "File Account" temporary) + internal procedure CreateAccount(var AccountToCopy: Record "Ext. SFTP Account"; Password: SecretText; Certificate: Text; CertificatePassword: SecretText; var TempFileAccount: Record "File Account" temporary) var NewAccount: Record "Ext. SFTP Account"; begin NewAccount.TransferFields(AccountToCopy); NewAccount.Id := CreateGuid(); - NewAccount.SetPassword(PasswordOrCertificate); + NewAccount.SetPassword(Password); case NewAccount."Authentication Type" of Enum::"Ext. SFTP Auth Type"::Password: - NewAccount.SetPassword(PasswordOrCertificate); + NewAccount.SetPassword(Certificate); Enum::"Ext. SFTP Auth Type"::Certificate: begin - NewAccount.SetCertificate(PasswordOrCertificate); + NewAccount.SetCertificate(Certificate); NewAccount.SetCertificatePassword(CertificatePassword); end; end; @@ -394,9 +394,12 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne TempFileAccount.Connector := Enum::"Ext. File Storage Connector"::"SFTP"; end; + [NonDebuggable] local procedure InitSFTPClient(var AccountId: Guid; var SFTPClient: Codeunit "SFTP Client") var SFTPAccount: Record "Ext. SFTP Account"; + Response: Codeunit "SFTP Operation Response"; + Stream: InStream; AccountDisabledErr: Label 'The account "%1" is disabled.', Comment = '%1 - Account Name'; begin SFTPAccount.Get(AccountId); @@ -407,13 +410,19 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne case SFTPAccount."Authentication Type" of Enum::"Ext. SFTP Auth Type"::Password: - SFTPClient.Initialize(SFTPAccount.Hostname, SFTPAccount.Port, SFTPAccount.Username, SFTPAccount.GetPassword(SFTPAccount."Password Key")); + Response := SFTPClient.Initialize(SFTPAccount.Hostname, SFTPAccount.Port, SFTPAccount.Username, SFTPAccount.GetPassword(SFTPAccount."Password Key")); Enum::"Ext. SFTP Auth Type"::Certificate: - if IsNullGuid(SFTPAccount."Certificate Key") then - SFTPClient.Initialize(SFTPAccount.Hostname, SFTPAccount.Port, SFTPAccount.Username, SFTPAccount.GetCertificate(SFTPAccount."Certificate Key")) - else - SFTPClient.Initialize(SFTPAccount.Hostname, SFTPAccount.Port, SFTPAccount.Username, SFTPAccount.GetCertificate(SFTPAccount."Certificate Key"), SFTPAccount.GetCertificatePassword(SFTPAccount."Certificate Password Key")); + begin + SFTPAccount.GetCertificate(SFTPAccount."Certificate Key").CreateInStream(Stream); + if IsNullGuid(SFTPAccount."Certificate Password Key") then + Response := SFTPClient.Initialize(SFTPAccount.Hostname, SFTPAccount.Port, SFTPAccount.Username, Stream) + else + Response := SFTPClient.Initialize(SFTPAccount.Hostname, SFTPAccount.Port, SFTPAccount.Username, Stream, SFTPAccount.GetCertificatePassword(SFTPAccount."Certificate Password Key")); + end; end; + + if Response.IsError() then + ShowError(Response); end; local procedure ShowError(var Response: Codeunit "SFTP Operation Response") From a4949f4724a741863e5d503fc4c23da79fe988b3 Mon Sep 17 00:00:00 2001 From: Thomas Williamson Date: Wed, 7 Jan 2026 11:29:53 +0100 Subject: [PATCH 09/11] Add code review suggestions --- .../App/app.json | 4 ++-- .../App/permissions/ExtSFTPEdit.PermissionSet.al | 2 +- .../permissions/ExtSFTPObjects.PermissionSet.al | 2 +- .../App/permissions/ExtSFTPRead.PermissionSet.al | 2 +- .../FileStorageAdminExtSFTP.PermissionSetExt.al | 2 +- .../FileStorageEditExtSFTP.PermissionSetExt.al | 2 +- .../App/src/ExtSFTPAccount.Page.al | 14 +++++++------- .../App/src/ExtSFTPAccount.Table.al | 2 +- .../App/src/ExtSFTPAccountWizard.Page.al | 4 ++-- .../App/src/ExtSFTPAuthType.Enum.al | 4 ++-- .../App/src/ExtSFTPConnector.EnumExt.al | 4 ++-- .../App/src/ExtSFTPConnectorImpl.Codeunit.al | 16 ++++------------ .../Test/app.json | 4 ++-- .../Test/src/ExtSFTPConnectorTest.Codeunit.al | 2 +- 14 files changed, 28 insertions(+), 36 deletions(-) diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/app.json b/src/Apps/W1/External File Storage - SFTP Connector/App/app.json index 7156b0ed7c..fe3bd58ea6 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/app.json +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/app.json @@ -23,8 +23,8 @@ "screenshots": [], "idRanges": [ { - "from": 4590, - "to": 4599 + "from": 4621, + "to": 4629 } ], "resourceExposurePolicy": { diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPEdit.PermissionSet.al b/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPEdit.PermissionSet.al index d27f41077c..b9b754a5dd 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPEdit.PermissionSet.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPEdit.PermissionSet.al @@ -5,7 +5,7 @@ namespace System.ExternalFileStorage; -permissionset 4590 "Ext. SFTP - Edit" +permissionset 4621 "Ext. SFTP - Edit" { Access = Public; Assignable = false; diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPObjects.PermissionSet.al b/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPObjects.PermissionSet.al index 4b85d897db..e9c9046ff3 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPObjects.PermissionSet.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPObjects.PermissionSet.al @@ -5,7 +5,7 @@ namespace System.ExternalFileStorage; -permissionset 4591 "Ext. SFTP - Objects" +permissionset 4622 "Ext. SFTP - Objects" { Access = Public; Assignable = false; diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPRead.PermissionSet.al b/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPRead.PermissionSet.al index 087039ee02..86482d2513 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPRead.PermissionSet.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/ExtSFTPRead.PermissionSet.al @@ -5,7 +5,7 @@ namespace System.ExternalFileStorage; -permissionset 4594 "Ext. SFTP - Read" +permissionset 4623 "Ext. SFTP - Read" { Access = Public; Assignable = false; diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/FileStorageAdminExtSFTP.PermissionSetExt.al b/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/FileStorageAdminExtSFTP.PermissionSetExt.al index 6c635e7271..ea2008d1cf 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/FileStorageAdminExtSFTP.PermissionSetExt.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/FileStorageAdminExtSFTP.PermissionSetExt.al @@ -5,7 +5,7 @@ namespace System.ExternalFileStorage; -permissionsetextension 4590 "File Storage - Admin - Ext. SFTP" extends "File Storage - Admin" +permissionsetextension 4621 "File Storage - Admin - Ext. SFTP" extends "File Storage - Admin" { IncludedPermissionSets = "Ext. SFTP - Edit"; } diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/FileStorageEditExtSFTP.PermissionSetExt.al b/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/FileStorageEditExtSFTP.PermissionSetExt.al index 0073d08c4a..d0be734af5 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/FileStorageEditExtSFTP.PermissionSetExt.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/permissions/FileStorageEditExtSFTP.PermissionSetExt.al @@ -5,7 +5,7 @@ namespace System.ExternalFileStorage; -permissionsetextension 4591 "File Storage - Edit - Ext. SFTP" extends "File Storage - Edit" +permissionsetextension 4622 "File Storage - Edit - Ext. SFTP" extends "File Storage - Edit" { IncludedPermissionSets = "Ext. SFTP - Read"; } diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Page.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Page.al index 64b9bde59f..494c526fa5 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Page.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Page.al @@ -8,7 +8,7 @@ namespace System.ExternalFileStorage; /// /// Displays an account that was registered via the SFTP connector. /// -page 4590 "Ext. SFTP Account" +page 4621 "Ext. SFTP Account" { ApplicationArea = All; Caption = 'SFTP Account'; @@ -53,14 +53,14 @@ page 4590 "Ext. SFTP Account" ShowCaption = false; Visible = PasswordVisible; - field(PasswordField; Passowrd) + field(PasswordField; Password) { Caption = 'Password'; ExtendedDatatype = Masked; ToolTip = 'Specifies the Password to access the SFTP Server.'; trigger OnValidate() begin - Rec.SetPassword(Passowrd); + Rec.SetPassword(Password); end; } } @@ -73,7 +73,7 @@ page 4590 "Ext. SFTP Account" { Caption = 'Certificate'; Editable = false; - ToolTip = 'Specifies the certificate file used for authentication. Click here to upload a new certificate file (.pfx, .cer, or .crt).'; + ToolTip = 'Specifies the key file used for authentication. Click here to upload a key file (.pk, .ppk, or .pub).'; trigger OnDrillDown() begin @@ -105,7 +105,7 @@ page 4590 "Ext. SFTP Account" PasswordVisible: Boolean; CertificateVisible: Boolean; [NonDebuggable] - Passowrd: Text; + Password: Text; [NonDebuggable] CertificatePassword, Certificate : Text; CertificateStatusText: Text; @@ -127,12 +127,12 @@ page 4590 "Ext. SFTP Account" local procedure MaskSensitiveFields() begin - Clear(Passowrd); + Clear(Password); Clear(Certificate); Clear(CertificatePassword); if not IsNullGuid(Rec."Password Key") then - Passowrd := '***'; + Password := '***'; if not IsNullGuid(Rec."Certificate Password Key") then CertificatePassword := '***'; diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al index 456005ef2f..532ce15cbc 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al @@ -11,7 +11,7 @@ using System.Utilities; /// /// Holds the information for all file accounts that are registered via the SFTP connector /// -table 4590 "Ext. SFTP Account" +table 4621 "Ext. SFTP Account" { Caption = 'SFTP Account'; DataClassification = CustomerContent; diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al index 37f418f31a..74696cdb3e 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccountWizard.Page.al @@ -10,7 +10,7 @@ using System.Environment; /// /// Displays an account that is being registered via the SFTP connector. /// -page 4591 "Ext. SFTP Account Wizard" +page 4622 "Ext. SFTP Account Wizard" { ApplicationArea = All; Caption = 'Setup SFTP Account'; @@ -122,7 +122,7 @@ page 4591 "Ext. SFTP Account Wizard" Caption = 'Certificate'; Editable = false; ShowMandatory = true; - ToolTip = 'Specifies the certificate file used for authentication. Click here to upload a certificate file (.pfx, .cer, or .crt).'; + ToolTip = 'Specifies the key file used for authentication. Click here to upload a key file (.pk, .ppk, or .pub).'; trigger OnDrillDown() begin diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAuthType.Enum.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAuthType.Enum.al index 57184b24da..0c2aff7247 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAuthType.Enum.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAuthType.Enum.al @@ -8,13 +8,13 @@ namespace System.ExternalFileStorage; /// /// Specifies the authentication types for SFTP accounts. /// -enum 4595 "Ext. SFTP Auth Type" +enum 4621 "Ext. SFTP Auth Type" { Extensible = false; Access = Public; /// - /// Authenticate password. + /// Authenticate using password. /// value(0; Password) { diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnector.EnumExt.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnector.EnumExt.al index fb5c00309d..3a2a7fa8a7 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnector.EnumExt.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnector.EnumExt.al @@ -8,12 +8,12 @@ namespace System.ExternalFileStorage; /// /// Enum extension to register the SFTP connector. /// -enumextension 4590 "Ext. SFTP Connector" extends "Ext. File Storage Connector" +enumextension 4621 "Ext. SFTP Connector" extends "Ext. File Storage Connector" { /// /// The SFTP connector. /// - value(4590; "SFTP") + value(4621; "SFTP") { Caption = 'SFTP'; Implementation = "External File Storage Connector" = "Ext. SFTP Connector Impl"; diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al index b8ee523b13..0d7236068f 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al @@ -10,7 +10,7 @@ using System.SFTPClient; using System.Text; using System.Utilities; -codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Connector" +codeunit 4621 "Ext. SFTP Connector Impl" implements "External File Storage Connector" { Access = Internal; InherentEntitlements = X; @@ -76,7 +76,7 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne InitPath(AccountId, Path); InitSFTPClient(AccountId, SFTPClient); - SFTPClient.GetFileAsStream(Path, TempBlobStream); + Response := SFTPClient.GetFileAsStream(Path, TempBlobStream); SFTPClient.Disconnect(); // Platform fix: For some reason the Stream from GetFileAsStream dies after leaving the interface @@ -97,11 +97,9 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne var SFTPClient: Codeunit "SFTP Client"; Response: Codeunit "SFTP Operation Response"; - ParentPath, FileName : Text; begin InitPath(AccountId, Path); InitSFTPClient(AccountId, SFTPClient); - SplitPath(Path, ParentPath, FileName); Response := SFTPClient.PutFileStream(Path, Stream); SFTPClient.Disconnect(); @@ -379,7 +377,7 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne case NewAccount."Authentication Type" of Enum::"Ext. SFTP Auth Type"::Password: - NewAccount.SetPassword(Certificate); + NewAccount.SetPassword(Password); Enum::"Ext. SFTP Auth Type"::Certificate: begin NewAccount.SetCertificate(Certificate); @@ -406,7 +404,7 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne if SFTPAccount.Disabled then Error(AccountDisabledErr, SFTPAccount.Name); - AddFingerPrints(SFTPAccount."Fingerprints", SFTPClient); + AddFingerprints(SFTPAccount."Fingerprints", SFTPClient); case SFTPAccount."Authentication Type" of Enum::"Ext. SFTP Auth Type"::Password: @@ -453,12 +451,6 @@ codeunit 4599 "Ext. SFTP Connector Impl" implements "External File Storage Conne exit(StrSubstNo(JoinPathTok, Parent.TrimEnd(PathSeparatorTok), Child.TrimStart(PathSeparatorTok))); end; - local procedure SplitPath(Path: Text; var ParentPath: Text; var FileName: Text) - begin - ParentPath := Path.TrimEnd(PathSeparatorTok).Substring(1, Path.LastIndexOf(PathSeparatorTok)); - FileName := Path.TrimEnd(PathSeparatorTok).Substring(Path.LastIndexOf(PathSeparatorTok) + 1); - end; - local procedure AddFingerprints(Fingerprints: Text; var SFTPClient: Codeunit "SFTP Client") var Fingerprint: Text; diff --git a/src/Apps/W1/External File Storage - SFTP Connector/Test/app.json b/src/Apps/W1/External File Storage - SFTP Connector/Test/app.json index d230162129..0d3b3a3311 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/Test/app.json +++ b/src/Apps/W1/External File Storage - SFTP Connector/Test/app.json @@ -41,8 +41,8 @@ "platform": "28.0.0.0", "idRanges": [ { - "from": 100000, - "to": 150000 + "from": 144590, + "to": 144599 } ], "resourceExposurePolicy": { diff --git a/src/Apps/W1/External File Storage - SFTP Connector/Test/src/ExtSFTPConnectorTest.Codeunit.al b/src/Apps/W1/External File Storage - SFTP Connector/Test/src/ExtSFTPConnectorTest.Codeunit.al index 01c0070409..7241327e0f 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/Test/src/ExtSFTPConnectorTest.Codeunit.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/Test/src/ExtSFTPConnectorTest.Codeunit.al @@ -55,7 +55,7 @@ codeunit 144591 "Ext. SFTP Connector Test" [Scope('OnPrem')] [HandlerFunctions('AccountRegisterPageHandler')] [TransactionModel(TransactionModel::AutoRollback)] - procedure TestEnviromentCleanupDisablesAccounts() + procedure TestEnvironmentCleanupDisablesAccounts() var FileAccount: Record "File Account"; ExtSFTPAccount: Record "Ext. SFTP Account"; From 363cb4c81f3a0e5b4df5230c1e70d1a507e0867a Mon Sep 17 00:00:00 2001 From: Thomas Williamson Date: Thu, 8 Jan 2026 17:51:57 +0100 Subject: [PATCH 10/11] Fix #6086 --- .../App/src/ExtSFTPAccount.Table.al | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al index 532ce15cbc..3c3af6b3b5 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al @@ -200,7 +200,7 @@ table 4621 "Ext. SFTP Account" local procedure TryDeleteIsolatedStorageValue(StorageKey: Guid) begin if not IsNullGuid(StorageKey) then - if IsolatedStorage.Delete(StorageKey) then; + if IsolatedStorage.Delete(StorageKey, DataScope::Company) then; end; local procedure SetIsolatedStorageValue(StorageKey: Guid; Value: SecretText; ErrorMessage: Text) From 8dff82857d75e738de5457a4172c80f2a852bf0b Mon Sep 17 00:00:00 2001 From: Thomas Williamson Date: Thu, 8 Jan 2026 18:07:30 +0100 Subject: [PATCH 11/11] Add code review suggestions --- .../App/src/ExtSFTPAccount.Table.al | 9 +++-- .../App/src/ExtSFTPConnectorImpl.Codeunit.al | 33 ++++++++++++------- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al index 3c3af6b3b5..49744be512 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPAccount.Table.al @@ -199,8 +199,13 @@ table 4621 "Ext. SFTP Account" local procedure TryDeleteIsolatedStorageValue(StorageKey: Guid) begin - if not IsNullGuid(StorageKey) then - if IsolatedStorage.Delete(StorageKey, DataScope::Company) then; + if IsNullGuid(StorageKey) then + exit; + + if not IsolatedStorage.Contains(Format(StorageKey), DataScope::Company) then + exit; + + IsolatedStorage.Delete(StorageKey, DataScope::Company); end; local procedure SetIsolatedStorageValue(StorageKey: Guid; Value: SecretText; ErrorMessage: Text) diff --git a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al index 0d7236068f..473feb955b 100644 --- a/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al +++ b/src/Apps/W1/External File Storage - SFTP Connector/App/src/ExtSFTPConnectorImpl.Codeunit.al @@ -79,12 +79,12 @@ codeunit 4621 "Ext. SFTP Connector Impl" implements "External File Storage Conne Response := SFTPClient.GetFileAsStream(Path, TempBlobStream); SFTPClient.Disconnect(); + if Response.IsError() then + ShowError(Response); + // Platform fix: For some reason the Stream from GetFileAsStream dies after leaving the interface Content.WriteFrom(TempBlobStream); Content.ReadAs(Stream); - - if Response.IsError() then - ShowError(Response); end; /// @@ -363,6 +363,12 @@ codeunit 4621 "Ext. SFTP Connector Impl" implements "External File Storage Conne if TempAccount."Base Relative Folder Path" = '' then exit(false); + if TempAccount.Username = '' then + exit(false); + + if TempAccount.Port = 0 then + exit(false); + exit(true); end; @@ -373,8 +379,6 @@ codeunit 4621 "Ext. SFTP Connector Impl" implements "External File Storage Conne NewAccount.TransferFields(AccountToCopy); NewAccount.Id := CreateGuid(); - NewAccount.SetPassword(Password); - case NewAccount."Authentication Type" of Enum::"Ext. SFTP Auth Type"::Password: NewAccount.SetPassword(Password); @@ -425,9 +429,9 @@ codeunit 4621 "Ext. SFTP Connector Impl" implements "External File Storage Conne local procedure ShowError(var Response: Codeunit "SFTP Operation Response") var - ErrorOccuredErr: Label 'An error occured.\%1', Comment = '%1 - Error message from SFTP Server'; + ErrorOccurredErr: Label 'An error occurred.\%1', Comment = '%1 - Error message from SFTP Server'; begin - Error(ErrorOccuredErr, Response.GetError()); + Error(ErrorOccurredErr, Response.GetError()); end; local procedure InitPath(AccountId: Guid; var Path: Text) @@ -463,13 +467,20 @@ codeunit 4621 "Ext. SFTP Connector Impl" implements "External File Storage Conne var SHA256PrefixTok: Label 'sha256:', Locked = true; MD5PrefixTok: Label 'md5:', Locked = true; + InvalidFingerprintErr: Label 'Fingerprint must start with "md5:" or "sha256:".'; begin Fingerprint := Fingerprint.Trim(); - if Fingerprint.StartsWith(SHA256PrefixTok) then - SFTPClient.AddFingerprintSHA256(Fingerprint.Substring(StrLen(SHA256PrefixTok) + 1)) - else - if Fingerprint.StartsWith(MD5PrefixTok) then + if Fingerprint = '' then + exit; + + case true of + Fingerprint.StartsWith(SHA256PrefixTok): + SFTPClient.AddFingerprintSHA256(Fingerprint.Substring(StrLen(SHA256PrefixTok) + 1)); + Fingerprint.StartsWith(MD5PrefixTok): SFTPClient.AddFingerprintMD5(Fingerprint.Substring(StrLen(MD5PrefixTok) + 1)); + else + Error(InvalidFingerprintErr); + end; end; [EventSubscriber(ObjectType::Codeunit, Codeunit::"Environment Cleanup", OnClearCompanyConfig, '', false, false)]