# SFTPGo NixOS test
#
# This NixOS test sets up a basic test scenario for the SFTPGo module
# and covers the following scenarios:
# - uploading a file via sftp
# - downloading the file over sftp
# - assert that the ACLs are respected
# - share a file between alice and bob (using sftp)
# - assert that eve cannot acceess the shared folder between alice and bob.
#
# Additional test coverage for the remaining protocols (i.e. ftp, http and webdav)
# would be a nice to have for the future.
{ pkgs, lib, ...  }:

let
  inherit (import ./ssh-keys.nix pkgs) snakeOilPrivateKey snakeOilPublicKey;

  # Returns an attributeset of users who are not system users.
  normalUsers = config:
    lib.filterAttrs (name: user: user.isNormalUser) config.users.users;

  # Returns true if a user is a member of the given group
  isMemberOf =
    config:
    # str
    groupName:
    # users.users attrset
    user:
      lib.any (x: x == user.name) config.users.groups.${groupName}.members;

  # Generates a valid SFTPGo user configuration for a given user
  # Will be converted to JSON and loaded on application startup.
  generateUserAttrSet =
    config:
    # attrset returned by config.users.users.<username>
    user: {
      # 0: user is disabled, login is not allowed
      # 1: user is enabled
      status = 1;

      username = user.name;
      password = ""; # disables password authentication
      public_keys = user.openssh.authorizedKeys.keys;
      email = "${user.name}@example.com";

      # User home directory on the local filesystem
      home_dir = "${config.services.sftpgo.dataDir}/users/${user.name}";

      # Defines a mapping between virtual SFTPGo paths and filesystem paths outside the user home directory.
      #
      # Supported for local filesystem only. If one or more of the specified folders are not
      # inside the dataprovider they will be automatically created.
      # You have to create the folder on the filesystem yourself
      virtual_folders =
        lib.optional (isMemberOf config sharedFolderName user) {
          name = sharedFolderName;
          mapped_path = "${config.services.sftpgo.dataDir}/${sharedFolderName}";
          virtual_path = "/${sharedFolderName}";
        };

      # Defines the ACL on the virtual filesystem
      permissions =
        lib.recursiveUpdate {
          "/" = [ "list" ];     # read-only top level directory
          "/private" = [ "*" ]; # private subdirectory, not shared with others
        } (lib.optionalAttrs (isMemberOf config "shared" user) {
          "/shared" = [ "*" ];
        });

      filters = {
        allowed_ip = [];
        denied_ip = [];
        web_client = [
          "password-change-disabled"
          "password-reset-disabled"
          "api-key-auth-change-disabled"
        ];
      };

      upload_bandwidth = 0; # unlimited
      download_bandwidth = 0; # unlimited
      expiration_date = 0; # means no expiration
      max_sessions = 0;
      quota_size = 0;
      quota_files = 0;
    };

  # Generates a json file containing a static configuration
  # of users and folders to import to SFTPGo.
  loadDataJson = config: pkgs.writeText "users-and-folders.json" (builtins.toJSON {
    users =
      lib.mapAttrsToList (name: user: generateUserAttrSet config user) (normalUsers config);

    folders = [
      {
        name = sharedFolderName;
        description = "shared folder";

        # 0: local filesystem
        # 1: AWS S3 compatible
        # 2: Google Cloud Storage
        filesystem.provider = 0;

        # Mapped path on the local filesystem
        mapped_path = "${config.services.sftpgo.dataDir}/${sharedFolderName}";

        # All users in the matching group gain access
        users = config.users.groups.${sharedFolderName}.members;
      }
    ];
  });

  # Generated Host Key for connecting to SFTPGo's sftp subsystem.
  snakeOilHostKey = pkgs.writeText "sftpgo_ed25519_host_key" ''
    -----BEGIN OPENSSH PRIVATE KEY-----
    b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
    QyNTUxOQAAACBOtQu6U135yxtrvUqPoozUymkjoNNPVK6rqjS936RLtQAAAJAXOMoSFzjK
    EgAAAAtzc2gtZWQyNTUxOQAAACBOtQu6U135yxtrvUqPoozUymkjoNNPVK6rqjS936RLtQ
    AAAEAoRLEV1VD80mg314ObySpfrCcUqtWoOSS3EtMPPhx08U61C7pTXfnLG2u9So+ijNTK
    aSOg009UrquqNL3fpEu1AAAADHNmdHBnb0BuaXhvcwE=
    -----END OPENSSH PRIVATE KEY-----
  '';

  adminUsername = "admin";
  adminPassword = "secretadminpassword";
  aliceUsername = "alice";
  alicePassword = "secretalicepassword";
  bobUsername = "bob";
  bobPassword = "secretbobpassword";
  eveUsername = "eve";
  evePassword = "secretevepassword";
  sharedFolderName = "shared";

  # A file for testing uploading via SFTP
  testFile = pkgs.writeText "test.txt" "hello world";
  sharedFile = pkgs.writeText "shared.txt" "shared content";

  # Define the for exposing SFTP
  sftpPort = 2022;

  # Define the for exposing HTTP
  httpPort = 8080;
in
{
  name = "sftpgo";

  meta.maintainers = with lib.maintainers; [ yayayayaka ];

  nodes = {
    server = { nodes, ... }: {
      networking.firewall.allowedTCPPorts = [ sftpPort httpPort ];

      # nodes.server.configure postgresql database
      services.postgresql = {
        enable = true;
        ensureDatabases = [ "sftpgo" ];
        ensureUsers = [{
          name = "sftpgo";
          ensureDBOwnership = true;
        }];
      };

      services.sftpgo = {
        enable = true;

        loadDataFile = (loadDataJson nodes.server);

        settings = {
          data_provider = {
            driver = "postgresql";
            name = "sftpgo";
            username = "sftpgo";
            host = "/run/postgresql";
            port = 5432;

            # Enables the possibility to create an initial admin user on first startup.
            create_default_admin = true;
          };

          httpd.bindings = [
            {
              address = ""; # listen on all interfaces
              port = httpPort;
              enable_https = false;

              enable_web_client = true;
              enable_web_admin = true;
            }
          ];

          # Enable sftpd
          sftpd = {
            bindings = [{
              address = ""; # listen on all interfaces
              port = sftpPort;
            }];
            host_keys = [ snakeOilHostKey ];
            password_authentication = false;
            keyboard_interactive_authentication = false;
          };
        };
      };

      systemd.services.sftpgo = {
        after = [ "postgresql.service"];
        environment = {
          # Update existing users
          SFTPGO_LOADDATA_MODE = "0";
          SFTPGO_DEFAULT_ADMIN_USERNAME = adminUsername;

          # This will end up in cleartext in the systemd service.
          # Don't use this approach in production!
          SFTPGO_DEFAULT_ADMIN_PASSWORD = adminPassword;
        };
      };

      # Sets up the folder hierarchy on the local filesystem
      systemd.tmpfiles.rules =
        let
          sftpgoUser = nodes.server.services.sftpgo.user;
          sftpgoGroup = nodes.server.services.sftpgo.group;
          statePath = nodes.server.services.sftpgo.dataDir;
        in [
          # Create state directory
          "d ${statePath} 0750 ${sftpgoUser} ${sftpgoGroup} -"
          "d ${statePath}/users 0750 ${sftpgoUser} ${sftpgoGroup} -"

          # Created shared folder directories
          "d ${statePath}/${sharedFolderName} 2770 ${sftpgoUser} ${sharedFolderName}   -"
        ]
        ++ lib.mapAttrsToList (name: user:
          # Create private user directories
          ''
            d ${statePath}/users/${user.name} 0700 ${sftpgoUser} ${sftpgoGroup} -
            d ${statePath}/users/${user.name}/private 0700 ${sftpgoUser} ${sftpgoGroup} -
          ''
        ) (normalUsers nodes.server);

      users.users =
        let
          commonAttrs = {
            isNormalUser = true;
            openssh.authorizedKeys.keys = [ snakeOilPublicKey ];
          };
        in {
          # SFTPGo admin user
          admin = commonAttrs // {
            password = adminPassword;
          };

          # Alice and bob share folders with each other
          alice = commonAttrs // {
            password = alicePassword;
            extraGroups = [ sharedFolderName ];
          };

          bob = commonAttrs // {
            password = bobPassword;
            extraGroups = [ sharedFolderName ];
          };

          # Eve has no shared folders
          eve = commonAttrs // {
            password = evePassword;
          };
        };

      users.groups.${sharedFolderName} = {};

      specialisation = {
        # A specialisation for asserting that SFTPGo can bind to privileged ports.
        privilegedPorts.configuration = { ... }: {
          networking.firewall.allowedTCPPorts = [ 22 80 ];
          services.sftpgo = {
            settings = {
              sftpd.bindings = lib.mkForce [{
                address = "";
                port = 22;
              }];

              httpd.bindings = lib.mkForce [{
                address = "";
                port = 80;
              }];
            };
          };
        };
      };
    };

    client = { nodes, ... }: {
      # Add the SFTPGo host key to the global known_hosts file
      programs.ssh.knownHosts =
        let
          commonAttrs = {
            publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE61C7pTXfnLG2u9So+ijNTKaSOg009UrquqNL3fpEu1";
          };
        in {
          "server" = commonAttrs;
          "[server]:2022" = commonAttrs;
        };
      };
  };

  testScript = { nodes, ... }: let
    # A function to generate test cases for wheter
    # a specified username is expected to access the shared folder.
    accessSharedFoldersSubtest =
      { # The username to run as
        username
        # Whether the tests are expected to succeed or not
      , shouldSucceed ? true
      }: ''
        with subtest("Test whether ${username} can access shared folders"):
            client.${if shouldSucceed then "succeed" else "fail"}("sftp -P ${toString sftpPort} -b ${
              pkgs.writeText "${username}-ls-${sharedFolderName}" ''
                ls ${sharedFolderName}
              ''
            } ${username}@server")
      '';
      statePath = nodes.server.services.sftpgo.dataDir;
  in ''
    start_all()

    client.wait_for_unit("default.target")
    server.wait_for_unit("sftpgo.service")

    with subtest("web client"):
        client.wait_until_succeeds("curl -sSf http://server:${toString httpPort}/web/client/login")

        # Ensure sftpgo found the static folder
        client.wait_until_succeeds("curl -o /dev/null -sSf http://server:${toString httpPort}/static/favicon.ico")

    with subtest("Setup SSH keys"):
        client.succeed("mkdir -m 700 /root/.ssh")
        client.succeed("cat ${snakeOilPrivateKey} > /root/.ssh/id_ecdsa")
        client.succeed("chmod 600 /root/.ssh/id_ecdsa")

    with subtest("Copy a file over sftp"):
        client.wait_until_succeeds("scp -P ${toString sftpPort} ${toString testFile} alice@server:/private/${testFile.name}")
        server.succeed("test -s ${statePath}/users/alice/private/${testFile.name}")

        # The configured ACL should prevent uploading files to the root directory
        client.fail("scp -P ${toString sftpPort} ${toString testFile} alice@server:/")

    with subtest("Attempting an interactive SSH sessions must fail"):
        client.fail("ssh -p ${toString sftpPort} alice@server")

    ${accessSharedFoldersSubtest {
      username = "alice";
      shouldSucceed = true;
    }}

    ${accessSharedFoldersSubtest {
      username = "bob";
      shouldSucceed = true;
    }}

    ${accessSharedFoldersSubtest {
      username = "eve";
      shouldSucceed = false;
    }}

    with subtest("Test sharing files"):
        # Alice uploads a file to shared folder
        client.succeed("scp -P ${toString sftpPort} ${toString sharedFile} alice@server:/${sharedFolderName}/${sharedFile.name}")
        server.succeed("test -s ${statePath}/${sharedFolderName}/${sharedFile.name}")

        # Bob downloads the file from shared folder
        client.succeed("scp -P ${toString sftpPort} bob@server:/shared/${sharedFile.name} ${sharedFile.name}")
        client.succeed("test -s ${sharedFile.name}")

        # Eve should not get the file from shared folder
        client.fail("scp -P ${toString sftpPort} eve@server:/shared/${sharedFile.name}")

    server.succeed("/run/current-system/specialisation/privilegedPorts/bin/switch-to-configuration test")

    client.wait_until_succeeds("sftp -P 22 -b ${pkgs.writeText "get-hello-world.txt" ''
      get /private/${testFile.name}
    ''} alice@server")
  '';
}
