Repository: litestar-org/litestar

Issue:
Enhancement: Self-signed certificate generation for CLI
### Summary

This is along the lines what Flask (actually Werkzeug) and Django-extensions have. A new CLI option (or two) would be added to the `run` command
- `--ssl-certfile` to specify the location of the certificate
- `--ssl-keyfile` to specify the location of the keyfile
- If only one of them is provided, the second file is assumed to be in the same directory
- If the files are not found, they are automatically generated

The logic is taken from [`runserver_plus` in Django Extensions](https://django-extensions.readthedocs.io/en/latest/runserver_plus.html#ssl) and the names are taken from [Uvicorn](https://www.uvicorn.org/settings/#https)

The paths would be then forwarded to Uvicorn.

It would require the `cryptography` extras (which are already used for client sessions) and the `cryptography` module would be used to generate the self-signed certificates. 

[HERE](https://github.com/pallets/werkzeug/blob/main/src/werkzeug/serving.py#L540) is how Werkzeug handles the cert generation and [HERE](https://github.com/django-extensions/django-extensions/blob/main/django_extensions/management/commands/runserver_plus.py#L404) is how Django-Extensions handles the logic.

It is only meant to be used for development purposes on a HTTPS connection of course and would only generate a self-signed certificate.

### Basic Example

`litestar run --ssl-certfile=cert/server.crt`  - looks for `server.key` inside of the `cert/` directory
`litestar run --ssl-keyfile=cert/server.key` - looks for `server.crt` inside of the `cert/` directory
`litestar run --ssl-cerfile=cert/server.crt --ssl-keyfile=security/keys/server.key`
- if both files exist, use them
- otherwise generate new ones using `cryptography`
- if `cryptography` extras are not installed, terminate and inform user

### Drawbacks and Impact

_No response_

### Unresolved questions

The exact logic and option names are open for discussion.

<!-- POLAR PLEDGE BADGE START -->
---

## Funding
* If you would like to see an issue prioritized, make a pledge towards it!
* We receive the pledge once the issue is completed & verified

<a href="https://polar.sh/litestar-org/litestar/issues/2335">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/litestar-org/litestar/issues/2335/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/litestar-org/litestar/issues/2335/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:
I like this idea, but I want to make on additional suggestion.

Even though we don't recommend it, I know people use this to deploy their apps, and we might want to "officially" support that option in the future. The presence of cert flags implies they might be used to deploy proper certs, so having them autogenerate certs if they're missing by default could lead to some serious issues. 

To prevent this, we could add an additional flag `--generate-dev-certs` (or a better name) that enables this explicitly.


@litestar-org/maintainers?
> I like this idea, but I want to make on additional suggestion.
> 
> Even though we don't recommend it, I know people use this to deploy their apps, and we might want to "officially" support that option in the future. The presence of cert flags implies they might be used to deploy proper certs, so having them autogenerate certs if they're missing by default could lead to some serious issues.
> 
> To prevent this, we could add an additional flag `--generate-dev-certs` (or a better name) that enables this explicitly.
> 
> @litestar-org/maintainers?

Yeah - erring on the side of caution sounds reasonable to me.
I'd imagine there are few non-dev scenarios where having a self-signed cert might also be desirable (although I can't think of one right now).  I do agree that it needs to an extra flag to do this.

I can't think of anything better than `--generate-dev-certs` or `--generate-self-signed-certs` either, so no strong opinions on that one.
How about something like [trustme](https://github.com/python-trio/trustme) instead?
> How about something like [trustme](https://github.com/python-trio/trustme) instead?

I like this approach as well.  @geeshta can you elaborate on your specific use case for self-signed certs?  Is it specifically for local development with SSL enabled?  Integration with `trustme` may be the easiest way to bake this functionality in.
> > How about something like [trustme](https://github.com/python-trio/trustme) instead?
> 
> I like this approach as well.  @geeshta can you elaborate on your specific use case for self-signed certs?  Is it specifically for local development with SSL enabled?  Integration with `trustme` may be the easiest way to bake this functionality in.

Yeah it's just to enable serving with HTTPS during development (I use some client side JavaScript that requires HTTPS).

I haven't heard about trustme before but I really like how django-extensions handles that.
I personally never needed the certificate to be actually signed by a CA as trustme provides though and saw that Werkzeug uses `cryptography` to generate self-signed certs.

Resolving patch:
diff --git a/litestar/cli/_utils.py b/litestar/cli/_utils.py
--- a/litestar/cli/_utils.py
+++ b/litestar/cli/_utils.py
@@ -5,6 +5,7 @@
 import inspect
 import sys
 from dataclasses import dataclass
+from datetime import datetime, timedelta, timezone
 from functools import wraps
 from itertools import chain
 from os import getenv
@@ -34,6 +35,7 @@
     import jsbeautifier  # noqa: F401
 
     JSBEAUTIFIER_INSTALLED = True
+
 if TYPE_CHECKING or not RICH_CLICK_INSTALLED:  # pragma: no cover
     from click import ClickException, Command, Context, Group, pass_context
 else:
@@ -98,6 +100,9 @@ class LitestarEnv:
     reload_dirs: tuple[str, ...] | None = None
     web_concurrency: int | None = None
     is_app_factory: bool = False
+    certfile_path: str | None = None
+    keyfile_path: str | None = None
+    create_self_signed_cert: bool = False
 
     @classmethod
     def from_env(cls, app_path: str | None, app_dir: Path | None = None) -> LitestarEnv:
@@ -140,6 +145,9 @@ def from_env(cls, app_path: str | None, app_dir: Path | None = None) -> Litestar
             web_concurrency=int(web_concurrency) if web_concurrency else None,
             is_app_factory=loaded_app.is_factory,
             cwd=cwd,
+            certfile_path=getenv("LITESTAR_SSL_CERT_PATH"),
+            keyfile_path=getenv("LITESTAR_SSL_KEY_PATH"),
+            create_self_signed_cert=_bool_from_env("LITESTAR_CREATE_SELF_SIGNED_CERT"),
         )
 
 
@@ -410,3 +418,118 @@ def show_app_info(app: Litestar) -> None:  # pragma: no cover
         table.add_row("Middlewares", ", ".join(middlewares))
 
     console.print(table)
+
+
+def validate_ssl_file_paths(certfile_arg: str | None, keyfile_arg: str | None) -> tuple[str, str] | tuple[None, None]:
+    """Validate whether given paths exist, are not directories and were both provided or none was. Return the resolved paths.
+
+    Args:
+        certfile_arg: path argument for the certificate file
+        keyfile_arg: path argument for the key file
+
+    Returns:
+        tuple of resolved paths converted to str or tuple of None's if no argument was provided
+    """
+    if certfile_arg is None and keyfile_arg is None:
+        return (None, None)
+
+    resolved_paths = []
+
+    for argname, arg in {"--ssl-certfile": certfile_arg, "--ssl-keyfile": keyfile_arg}.items():
+        if arg is None:
+            raise LitestarCLIException(f"No value provided for {argname}")
+        path = Path(arg).resolve()
+        if path.is_dir():
+            raise LitestarCLIException(f"Path provided for {argname} is a directory: {path}")
+        if not path.exists():
+            raise LitestarCLIException(f"File provided for {argname} was not found: {path}")
+        resolved_paths.append(str(path))
+
+    return tuple(resolved_paths)  # type: ignore
+
+
+def create_ssl_files(
+    certfile_arg: str | None, keyfile_arg: str | None, common_name: str = "localhost"
+) -> tuple[str, str]:
+    """Validate whether both files were provided, are not directories, their parent dirs exist and either both files exists or none does.
+    If neither file exists, create a self-signed ssl certificate and a passwordless key at the location.
+
+    Args:
+        certfile_arg: path argument for the certificate file
+        keyfile_arg: path argument for the key file
+        common_name: the CN to be used as cert issuer and subject
+
+    Returns:
+        resolved paths of the found or generated files
+    """
+    resolved_paths = []
+
+    for argname, arg in {"--ssl-certfile": certfile_arg, "--ssl-keyfile": keyfile_arg}.items():
+        if arg is None:
+            raise LitestarCLIException(f"No value provided for {argname}")
+        path = Path(arg).resolve()
+        if path.is_dir():
+            raise LitestarCLIException(f"Path provided for {argname} is a directory: {path}")
+        if not (parent_dir := path.parent).exists():
+            raise LitestarCLIException(
+                f"Could not create file, parent directory for {argname} doesn't exist: {parent_dir}"
+            )
+        resolved_paths.append(path)
+
+    if (not resolved_paths[0].exists()) ^ (not resolved_paths[1].exists()):
+        raise LitestarCLIException(
+            "Both certificate and key file must exists or both must not exists when using --create-self-signed-cert"
+        )
+
+    if (not resolved_paths[0].exists()) and (not resolved_paths[1].exists()):
+        _generate_self_signed_cert(resolved_paths[0], resolved_paths[1], common_name)
+
+    return (str(resolved_paths[0]), str(resolved_paths[1]))
+
+
+def _generate_self_signed_cert(certfile_path: Path, keyfile_path: Path, common_name: str) -> None:
+    """Create a self-signed certificate using the cryptography modules at given paths"""
+    try:
+        from cryptography import x509
+        from cryptography.hazmat.backends import default_backend
+        from cryptography.hazmat.primitives import hashes, serialization
+        from cryptography.hazmat.primitives.asymmetric import rsa
+        from cryptography.x509.oid import NameOID
+    except ImportError as err:
+        raise LitestarCLIException(
+            "Cryptogpraphy must be installed when using --create-self-signed-cert\nPlease install the litestar[cryptography] extras"
+        ) from err
+
+    subject = x509.Name(
+        [
+            x509.NameAttribute(NameOID.COMMON_NAME, common_name),
+            x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Development Certificate"),
+        ]
+    )
+
+    key = rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend())
+
+    cert = (
+        x509.CertificateBuilder()
+        .subject_name(subject)
+        .issuer_name(subject)
+        .public_key(key.public_key())
+        .serial_number(x509.random_serial_number())
+        .not_valid_before(datetime.now(tz=timezone.utc))
+        .not_valid_after(datetime.now(tz=timezone.utc) + timedelta(days=365))
+        .add_extension(x509.SubjectAlternativeName([x509.DNSName(common_name)]), critical=False)
+        .add_extension(x509.ExtendedKeyUsage([x509.OID_SERVER_AUTH]), critical=False)
+        .sign(key, hashes.SHA256(), default_backend())
+    )
+
+    with certfile_path.open("wb") as cert_file:
+        cert_file.write(cert.public_bytes(serialization.Encoding.PEM))
+
+    with keyfile_path.open("wb") as key_file:
+        key_file.write(
+            key.private_bytes(
+                encoding=serialization.Encoding.PEM,
+                format=serialization.PrivateFormat.TraditionalOpenSSL,
+                encryption_algorithm=serialization.NoEncryption(),
+            )
+        )
diff --git a/litestar/cli/commands/core.py b/litestar/cli/commands/core.py
--- a/litestar/cli/commands/core.py
+++ b/litestar/cli/commands/core.py
@@ -9,7 +9,15 @@
 
 from rich.tree import Tree
 
-from litestar.cli._utils import RICH_CLICK_INSTALLED, UVICORN_INSTALLED, LitestarEnv, console, show_app_info
+from litestar.cli._utils import (
+    RICH_CLICK_INSTALLED,
+    UVICORN_INSTALLED,
+    LitestarEnv,
+    console,
+    create_ssl_files,
+    show_app_info,
+    validate_ssl_file_paths,
+)
 from litestar.routes import HTTPRoute, WebSocketRoute
 from litestar.utils.helpers import unwrap_partial
 
@@ -85,6 +93,13 @@ def info_command(app: Litestar) -> None:
 @option("-U", "--uds", "--unix-domain-socket", help="Bind to a UNIX domain socket.", default=None, show_default=True)
 @option("-d", "--debug", help="Run app in debug mode", is_flag=True)
 @option("-P", "--pdb", "--use-pdb", help="Drop into PDB on an exception", is_flag=True)
+@option("--ssl-certfile", help="Location of the SSL cert file", default=None)
+@option("--ssl-keyfile", help="Location of the SSL key file", default=None)
+@option(
+    "--create-self-signed-cert",
+    help="If certificate and key are not found at specified locations, create a self-signed certificate and a key",
+    is_flag=True,
+)
 def run_command(
     reload: bool,
     port: int,
@@ -95,6 +110,9 @@ def run_command(
     debug: bool,
     reload_dir: tuple[str, ...],
     pdb: bool,
+    ssl_certfile: str | None,
+    ssl_keyfile: str | None,
+    create_self_signed_cert: bool,
     ctx: Context,
 ) -> None:
     """Run a Litestar app; requires ``uvicorn``.
@@ -138,6 +156,16 @@ def run_command(
     reload = env.reload or reload or bool(reload_dirs)
     workers = env.web_concurrency or wc
 
+    ssl_certfile = ssl_certfile or env.certfile_path
+    ssl_keyfile = ssl_keyfile or env.keyfile_path
+    create_self_signed_cert = create_self_signed_cert or env.create_self_signed_cert
+
+    certfile_path, keyfile_path = (
+        create_ssl_files(ssl_certfile, ssl_keyfile, host)
+        if create_self_signed_cert
+        else validate_ssl_file_paths(ssl_certfile, ssl_keyfile)
+    )
+
     console.rule("[yellow]Starting server process", align="left")
 
     show_app_info(app)
@@ -153,6 +181,8 @@ def run_command(
             fd=fd,
             uds=uds,
             factory=env.is_app_factory,
+            ssl_certfile=certfile_path,
+            ssl_keyfile=keyfile_path,
         )
     else:
         # invoke uvicorn in a subprocess to be able to use the --reload flag. see
@@ -169,6 +199,8 @@ def run_command(
             "port": port,
             "workers": workers,
             "factory": env.is_app_factory,
+            "ssl-certfile": certfile_path,
+            "ssl-keyfile": keyfile_path,
         }
         if fd is not None:
             process_args["fd"] = fd


Test patch:
diff --git a/tests/unit/test_cli/test_core_commands.py b/tests/unit/test_cli/test_core_commands.py
--- a/tests/unit/test_cli/test_core_commands.py
+++ b/tests/unit/test_cli/test_core_commands.py
@@ -116,11 +116,11 @@ def test_run_command(
 
     if web_concurrency is None:
         web_concurrency = 1
-
     elif set_in_env:
         monkeypatch.setenv("WEB_CONCURRENCY", str(web_concurrency))
     else:
         args.extend(["--web-concurrency", str(web_concurrency)])
+
     if reload_dir is not None:
         if set_in_env:
             monkeypatch.setenv("LITESTAR_RELOAD_DIRS", ",".join(reload_dir))
@@ -142,6 +142,8 @@ def test_run_command(
             f"{path.stem}:app",
             f"--host={host}",
             f"--port={port}",
+            "--ssl-certfile=None",
+            "--ssl-keyfile=None",
         ]
         if fd is not None:
             expected_args.append(f"--fd={fd}")
@@ -158,7 +160,14 @@ def test_run_command(
     else:
         mock_subprocess_run.assert_not_called()
         mock_uvicorn_run.assert_called_once_with(
-            app=f"{path.stem}:app", host=host, port=port, uds=uds, fd=fd, factory=False
+            app=f"{path.stem}:app",
+            host=host,
+            port=port,
+            uds=uds,
+            fd=fd,
+            factory=False,
+            ssl_certfile=None,
+            ssl_keyfile=None,
         )
 
     mock_show_app_info.assert_called_once()
@@ -190,7 +199,14 @@ def test_run_command_with_autodiscover_app_factory(
     assert result.exit_code == 0
 
     mock_uvicorn_run.assert_called_once_with(
-        app=f"{path.stem}:{factory_name}", host="127.0.0.1", port=8000, factory=True, uds=None, fd=None
+        app=f"{path.stem}:{factory_name}",
+        host="127.0.0.1",
+        port=8000,
+        factory=True,
+        uds=None,
+        fd=None,
+        ssl_certfile=None,
+        ssl_keyfile=None,
     )
 
 
@@ -205,7 +221,14 @@ def test_run_command_with_app_factory(
     assert result.exit_code == 0
 
     mock_uvicorn_run.assert_called_once_with(
-        app=str(app_path), host="127.0.0.1", port=8000, factory=True, uds=None, fd=None
+        app=str(app_path),
+        host="127.0.0.1",
+        port=8000,
+        factory=True,
+        uds=None,
+        fd=None,
+        ssl_certfile=None,
+        ssl_keyfile=None,
     )
 
 
diff --git a/tests/unit/test_cli/test_ssl.py b/tests/unit/test_cli/test_ssl.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/test_cli/test_ssl.py
@@ -0,0 +1,341 @@
+import sys
+from pathlib import Path
+from typing import Optional, Protocol, cast
+from unittest.mock import MagicMock
+
+import pytest
+from click import ClickException
+from click.testing import CliRunner
+from pytest_mock import MockerFixture
+
+from litestar.cli.main import litestar_group as cli_command
+
+
+class GetClickExceptionFixture(Protocol):
+    def __call__(self, exception: SystemExit) -> ClickException:
+        ...
+
+
+@pytest.fixture
+def get_click_exception() -> GetClickExceptionFixture:
+    def _get_click_exception(exception: SystemExit) -> ClickException:
+        exc = exception
+        while exc.__context__ is not None:
+            if isinstance(exc, ClickException):
+                break
+            exc = exc.__context__  # type: ignore[assignment]
+        return cast(ClickException, exc)
+
+    return _get_click_exception
+
+
+@pytest.mark.parametrize("create_self_signed_cert", (True, False))
+@pytest.mark.usefixtures("mock_uvicorn_run")
+def test_both_files_provided(app_file: Path, runner: CliRunner, create_self_signed_cert: bool) -> None:
+    path = app_file
+    app_path = f"{path.stem}:app"
+
+    cert_path = path.parent / "cert.pem"
+    with cert_path.open("wb") as certfile:
+        certfile.write(b"certfile")
+
+    key_path = path.parent / "key.pem"
+    with key_path.open("wb") as keyfile:
+        keyfile.write(b"keyfile")
+
+    args = ["--app", app_path, "run", "--ssl-certfile", str(cert_path), "--ssl-keyfile", str(key_path)]
+
+    if create_self_signed_cert:
+        args.append("--create-self-signed-cert")
+
+    result = runner.invoke(cli_command, args)
+
+    assert result.exception is None
+    assert result.exit_code == 0
+
+    if create_self_signed_cert:
+        with cert_path.open("rb") as certfile:
+            assert certfile.read() == b"certfile"
+
+        with key_path.open("rb") as keyfile:
+            assert keyfile.read() == b"keyfile"
+
+
+@pytest.mark.parametrize("create_self_signed_cert", (True, False))
+@pytest.mark.parametrize(
+    "ssl_certfile, ssl_keyfile",
+    [("directory", "exists.pem"), ("exists.pem", "directory")],
+)
+def test_path_is_a_directory(
+    app_file: Path,
+    runner: CliRunner,
+    ssl_certfile: str,
+    ssl_keyfile: str,
+    create_self_signed_cert: bool,
+    get_click_exception: GetClickExceptionFixture,
+) -> None:
+    path = app_file
+    app_path = f"{path.stem}:app"
+
+    (path.parent / "exists.pem").touch()
+    (path.parent / "directory").mkdir(exist_ok=True)
+
+    args = ["--app", app_path, "run", "--ssl-certfile", ssl_certfile, "--ssl-keyfile", ssl_keyfile]
+
+    if create_self_signed_cert:
+        args.append("--create-self-signed-cert")
+
+    result = runner.invoke(cli_command, args)
+
+    assert result.exit_code == 1
+
+    assert isinstance(result.exception, SystemExit)
+    exc = get_click_exception(result.exception)
+    assert "Path provided for" in exc.message
+    assert "is a directory" in exc.message
+
+
+@pytest.mark.parametrize("create_self_signed_cert", (True, False))
+@pytest.mark.parametrize(
+    "ssl_certfile, ssl_keyfile",
+    [("exists.pem", None), (None, "exists.pem")],
+)
+def test_one_file_provided(
+    app_file: Path,
+    runner: CliRunner,
+    ssl_certfile: Optional[str],
+    ssl_keyfile: Optional[str],
+    create_self_signed_cert: bool,
+    get_click_exception: GetClickExceptionFixture,
+) -> None:
+    path = app_file
+    app_path = f"{path.stem}:app"
+
+    (path.parent / "exists.pem").touch()
+
+    args = ["--app", app_path, "run"]
+
+    if ssl_certfile is not None:
+        args.extend(["--ssl-certfile", str(ssl_certfile)])
+
+    if ssl_keyfile is not None:
+        args.extend(["--ssl-keyfile", str(ssl_keyfile)])
+
+    if create_self_signed_cert:
+        args.append("--create-self-signed-cert")
+
+    result = runner.invoke(cli_command, args)
+
+    assert result.exit_code == 1
+
+    assert isinstance(result.exception, SystemExit)
+    exc = get_click_exception(result.exception)
+    assert "No value provided for" in exc.message
+
+
+@pytest.mark.parametrize("create_self_signed_cert", (True, False))
+@pytest.mark.parametrize(
+    "ssl_certfile, ssl_keyfile",
+    [("not_exists.pem", "exists.pem"), ("exists.pem", "not_exists.pem")],
+)
+def test_one_file_not_found(
+    app_file: Path,
+    runner: CliRunner,
+    ssl_certfile: str,
+    ssl_keyfile: str,
+    create_self_signed_cert: bool,
+    get_click_exception: GetClickExceptionFixture,
+) -> None:
+    path = app_file
+    app_path = f"{path.stem}:app"
+
+    (path.parent / "exists.pem").touch()
+
+    args = ["--app", app_path, "run"]
+
+    if ssl_certfile is not None:
+        args.extend(["--ssl-certfile", str(ssl_certfile)])
+
+    if ssl_keyfile is not None:
+        args.extend(["--ssl-keyfile", str(ssl_keyfile)])
+
+    if create_self_signed_cert:
+        args.append("--create-self-signed-cert")
+
+    result = runner.invoke(cli_command, args)
+
+    assert result.exit_code == 1
+
+    assert isinstance(result.exception, SystemExit)
+    exc = get_click_exception(result.exception)
+    if create_self_signed_cert:
+        assert (
+            "Both certificate and key file must exists or both must not exists when using --create-self-signed-cert"
+            in exc.message
+        )
+    else:
+        assert "File provided for" in exc.message
+        assert "was not found" in exc.message
+
+
+@pytest.mark.parametrize(
+    "ssl_certfile, ssl_keyfile",
+    [("dir_exists/file.pem", "dir_not_exists/file.pem"), ("dir_not_exists/file.pem", "dir_exists/file.pem")],
+)
+def test_file_parent_doesnt_exist(
+    app_file: Path,
+    runner: CliRunner,
+    ssl_certfile: str,
+    ssl_keyfile: str,
+    get_click_exception: GetClickExceptionFixture,
+) -> None:
+    path = app_file
+    app_path = f"{path.stem}:app"
+
+    (path.parent / "dir_exists").mkdir(exist_ok=True)
+
+    args = [
+        "--app",
+        app_path,
+        "run",
+        "--ssl-certfile",
+        ssl_certfile,
+        "--ssl-keyfile",
+        ssl_keyfile,
+        "--create-self-signed-cert",
+    ]
+
+    result = runner.invoke(cli_command, args)
+
+    assert result.exit_code == 1
+
+    assert isinstance(result.exception, SystemExit)
+    exc = get_click_exception(result.exception)
+    assert "Could not create file, parent directory for" in exc.message
+    assert "doesn't exist" in exc.message
+
+
+def test_without_cryptography_installed(
+    app_file: Path,
+    runner: CliRunner,
+    get_click_exception: GetClickExceptionFixture,
+    mocker: MockerFixture,
+) -> None:
+    mocker.patch.dict("sys.modules", {"cryptography": None})
+
+    path = app_file
+    app_path = f"{path.stem}:app"
+
+    args = [
+        "--app",
+        app_path,
+        "run",
+        "--ssl-certfile",
+        "certfile.pem",
+        "--ssl-keyfile",
+        "keyfile.pem",
+        "--create-self-signed-cert",
+    ]
+
+    result = runner.invoke(cli_command, args)
+
+    assert result.exit_code == 1
+
+    assert isinstance(result.exception, SystemExit)
+    exc = get_click_exception(result.exception)
+    assert "Cryptogpraphy must be installed when using --create-self-signed-cert" in exc.message
+
+
+@pytest.mark.usefixtures("mock_uvicorn_run")
+def test_create_certificates(app_file: Path, runner: CliRunner) -> None:
+    path = app_file
+    app_path = f"{path.stem}:app"
+
+    certfile_path = path.parent / "certificate.pem"
+    keyfile_path = path.parent / "key.pem"
+
+    args = [
+        "--app",
+        app_path,
+        "run",
+        "--ssl-certfile",
+        str(certfile_path),
+        "--ssl-keyfile",
+        str(keyfile_path),
+        "--create-self-signed-cert",
+    ]
+
+    result = runner.invoke(cli_command, args)
+
+    assert result.exit_code == 0
+    assert result.exception is None
+
+    assert certfile_path.exists()
+    assert keyfile_path.exists()
+
+
+@pytest.mark.parametrize(
+    "ssl_certfile, ssl_keyfile, create_self_signed_cert", [(None, None, False), ("cert.pem", "key.pem", True)]
+)
+@pytest.mark.parametrize("run_as_subprocess", (True, False))
+def test_arguments_passed(
+    app_file: Path,
+    runner: CliRunner,
+    mock_subprocess_run: MagicMock,
+    mock_uvicorn_run: MagicMock,
+    ssl_certfile: Optional[str],
+    ssl_keyfile: Optional[str],
+    create_self_signed_cert: bool,
+    run_as_subprocess: bool,
+) -> None:
+    path = app_file
+    app_path = f"{path.stem}:app"
+
+    project_path = path.parent
+
+    args = ["--app", app_path, "run"]
+
+    if run_as_subprocess:
+        args.extend(["--web-concurrency", "2"])
+
+    if ssl_certfile is not None:
+        args.extend(["--ssl-certfile", str(ssl_certfile)])
+
+    if ssl_keyfile is not None:
+        args.extend(["--ssl-keyfile", str(ssl_keyfile)])
+
+    if create_self_signed_cert:
+        args.append("--create-self-signed-cert")
+
+    result = runner.invoke(cli_command, args)
+
+    assert result.exit_code == 0
+    assert result.exception is None
+
+    if run_as_subprocess:
+        expected_args = [
+            sys.executable,
+            "-m",
+            "uvicorn",
+            f"{path.stem}:app",
+            "--host=127.0.0.1",
+            "--port=8000",
+            "--workers=2",
+            f"--ssl-certfile={None if ssl_certfile is None else str(project_path / ssl_certfile)}",
+            f"--ssl-keyfile={None if ssl_keyfile is None else str(project_path / ssl_keyfile)}",
+        ]
+        mock_subprocess_run.assert_called_once()
+        assert sorted(mock_subprocess_run.call_args_list[0].args[0]) == sorted(expected_args)
+
+    else:
+        mock_subprocess_run.assert_not_called()
+        mock_uvicorn_run.assert_called_once_with(
+            app=f"{path.stem}:app",
+            host="127.0.0.1",
+            port=8000,
+            factory=False,
+            fd=None,
+            uds=None,
+            ssl_certfile=(None if ssl_certfile is None else str(project_path / ssl_certfile)),
+            ssl_keyfile=(None if ssl_keyfile is None else str(project_path / ssl_keyfile)),
+        )