Repository: beeware/briefcase

Issue:
Validate app document types before passing to cookiecutter
### Describe the bug

When a `[tool.briefcase.app.<app name>.document_type.<extension>]` table is configured incorrectly, the error reporting is a little opaque.

```
❯ briefcase create -vv

Application 'helloworld' already exists; overwrite [y/N]? y

[helloworld] Removing old application bundle...

[helloworld] Generating application template...
Using app template: https://github.com/beeware/briefcase-macOS-app-template.git, branch v0.3.18
Template branch None not found; falling back to development template
Using existing template (sha 17b3f5fa170e7d3490e399350502633994d4316b, updated Thu May  2 01:58:29 2024)

Log saved to /Users/user/tmp/beeware/helloworld/logs/briefcase.2024_05_02-19_11_59.create.log

Traceback (most recent call last):
  File "/Users/user/.pyenv/versions/briefcase-3.12/lib/python3.12/site-packages/git/util.py", line 1209, in __getitem__
    return getattr(self, index)
           ^^^^^^^^^^^^^^^^^^^^
  File "/Users/user/.pyenv/versions/briefcase-3.12/lib/python3.12/site-packages/git/util.py", line 1198, in __getattr__
    return list.__getattribute__(self, attr)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'IterableList' object has no attribute 'origin/v0.3.18'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/Users/user/github/beeware/briefcase/src/briefcase/commands/base.py", line 919, in update_cookiecutter_cache
    head = remote.refs[branch]
           ~~~~~~~~~~~^^^^^^^^
  File "/Users/user/.pyenv/versions/briefcase-3.12/lib/python3.12/site-packages/git/util.py", line 1211, in __getitem__
    raise IndexError("No item found with id %r" % (self._prefix + index)) from e
IndexError: No item found with id 'origin/v0.3.18'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/Users/user/github/beeware/briefcase/src/briefcase/commands/base.py", line 1017, in generate_template
    self._generate_template(
  File "/Users/user/github/beeware/briefcase/src/briefcase/commands/base.py", line 958, in _generate_template
    cached_template = self.update_cookiecutter_cache(
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/user/github/beeware/briefcase/src/briefcase/commands/base.py", line 928, in update_cookiecutter_cache
    raise TemplateUnsupportedVersion(branch) from e
briefcase.exceptions.TemplateUnsupportedVersion: Could not find a template branch for Briefcase v0.3.18.

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/Users/user/.pyenv/versions/briefcase-3.12/lib/python3.12/site-packages/cookiecutter/generate.py", line 409, in generate_files
    generate_file(
  File "/Users/user/.pyenv/versions/briefcase-3.12/lib/python3.12/site-packages/cookiecutter/generate.py", line 209, in generate_file
    rendered_file = tmpl.render(**context)
                    ^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/user/.pyenv/versions/briefcase-3.12/lib/python3.12/site-packages/jinja2/environment.py", line 1301, in render
    self.environment.handle_exception()
  File "/Users/user/.pyenv/versions/briefcase-3.12/lib/python3.12/site-packages/jinja2/environment.py", line 936, in handle_exception
    raise rewrite_traceback_stack(source=source)
  File "{{ cookiecutter.formal_name }}.app/Contents/Info.plist", line 75, in top-level template code
jinja2.exceptions.UndefinedError: 'dict object' has no attribute 'extension'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/Users/user/.pyenv/versions/briefcase-3.12/bin/briefcase", line 8, in <module>
    sys.exit(main())
             ^^^^^^
  File "/Users/user/github/beeware/briefcase/src/briefcase/__main__.py", line 29, in main
    command(**options)
  File "/Users/user/github/beeware/briefcase/src/briefcase/commands/create.py", line 818, in __call__
    state = self.create_app(app, **full_options(state, options))
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/user/github/beeware/briefcase/src/briefcase/commands/create.py", line 765, in create_app
    self.generate_app_template(app=app)
  File "/Users/user/github/beeware/briefcase/src/briefcase/commands/create.py", line 237, in generate_app_template
    self.generate_template(
  File "/Users/user/github/beeware/briefcase/src/briefcase/commands/base.py", line 1035, in generate_template
    self._generate_template(
  File "/Users/user/github/beeware/briefcase/src/briefcase/commands/base.py", line 967, in _generate_template
    self.tools.cookiecutter(
  File "/Users/user/.pyenv/versions/briefcase-3.12/lib/python3.12/site-packages/cookiecutter/main.py", line 182, in cookiecutter
    result = generate_files(
             ^^^^^^^^^^^^^^^
  File "/Users/user/.pyenv/versions/briefcase-3.12/lib/python3.12/site-packages/cookiecutter/generate.py", line 416, in generate_files
    raise UndefinedVariableInTemplate(msg, err, context) from err
cookiecutter.exceptions.UndefinedVariableInTemplate: Unable to create file '{{ cookiecutter.formal_name }}.app/Contents/Info.plist'. Error message: 'dict object' has no attribute 'extension'. Context: OrderedDict({'cookiecutter': OrderedDict({'format': 'app', 'formal_name': 'Hello World', 'app_name': 'helloworld', 'module_name': 'helloworld', 'info': {}, 'entitlements': {'com.apple.security.cs.allow-unsigned-executable-memory': True, 'com.apple.security.cs.disable-library-validation': True}, 'document_types': {'png': {'icon': 'asdf', 'description': 'description', 'url': 'zxcv://xcvb'}}, 'version': '0.0.1', 'build': '1', 'bundle': 'com.example', 'python_version': '3.12.1', '_extensions': ['briefcase.integrations.cookiecutter.PythonVersionExtension', 'briefcase.integrations.cookiecutter.PListExtension'], '_template': '/Users/user/.cookiecutters/briefcase-macOS-app-template', '_output_dir': '/Users/user/tmp/beeware/helloworld/build/helloworld/macos', '_repo_dir': '/Users/user/.cookiecutters/briefcase-macOS-app-template', '_checkout': 'main'}), '_cookiecutter': {'format': 'app', 'formal_name': 'Hello World', 'app_name': 'helloworld', 'module_name': 'helloworld', 'info': {}, 'entitlements': {'com.apple.security.cs.allow-unsigned-executable-memory': True, 'com.apple.security.cs.disable-library-validation': True}, 'document_types': {'png': {'icon': 'asdf', 'description': 'description', 'url': 'zxcv://xcvb'}}, 'version': '0.0.1', 'build': '1', 'bundle': 'com.example', 'python_version': '3.12.1'}})
```

### Steps to reproduce

Configure a `[tool.briefcase.app.<app name>.document_type.<extension>]` table without the `extension` field....or any of the other fields and run `briefcase create` (macOS only currently).

### Expected behavior

Users are plainly informed of the document type misconfiguration.

### Screenshots

_No response_

### Environment

- Operating System: Sonoma
- Python version: 3.12.1
- Software versions:
  - Briefcase: `0.3.18.dev279+gdbe11cf0`

### Logs

[briefcase.2024_05_02-19_16_39.create.log](https://github.com/beeware/briefcase/files/15194907/briefcase.2024_05_02-19_16_39.create.log)


### Additional context

_No response_

Discussion:

Patch:
diff --git a/changes/1846.bugfix.rst b/changes/1846.bugfix.rst
new file mode 100644
index 00000000..3ceb0bf6
--- /dev/null
+++ b/changes/1846.bugfix.rst
@@ -0,0 +1,1 @@
+Document type declarations are now fully validated.
diff --git a/pyproject.toml b/pyproject.toml
index b70e116f..5a8e2b3b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -101,9 +101,8 @@ dev = [
     # Pre-commit 3.6.0 deprecated support for Python 3.8
     "pre-commit == 3.5.0 ; python_version < '3.9'",
     "pre-commit == 3.7.1 ; python_version >= '3.9'",
-    "pytest == 8.3.1",
-    # Having xdist in the pytest environment causes some weird failures on Windows with Py3.13
-    "pytest-xdist == 3.6.1 ; python_version < '3.13'",
+    "pytest == 8.3.2",
+    "pytest-xdist == 3.6.1",
     "setuptools_scm == 8.1.0",
     "tox == 4.16.0",
 ]
diff --git a/src/briefcase/commands/base.py b/src/briefcase/commands/base.py
index 96c84248..d96b75d1 100644
--- a/src/briefcase/commands/base.py
+++ b/src/briefcase/commands/base.py
@@ -1035,7 +1035,6 @@ Did you run Briefcase in a project directory that contains {filename.name!r}?"""
         )
 
         self.logger.configure_stdlib_logging("cookiecutter")
-
         try:
             # Unroll the template.
             self.tools.cookiecutter(
@@ -1059,6 +1058,8 @@ Did you run Briefcase in a project directory that contains {filename.name!r}?"""
         except cookiecutter_exceptions.RepositoryCloneFailed as e:
             # Branch does not exist.
             raise TemplateUnsupportedVersion(branch) from e
+        except cookiecutter_exceptions.UndefinedVariableInTemplate as e:
+            raise BriefcaseConfigError(e.message) from e
 
     def generate_template(
         self,
diff --git a/src/briefcase/commands/convert.py b/src/briefcase/commands/convert.py
index 48c6c965..5d408a8a 100644
--- a/src/briefcase/commands/convert.py
+++ b/src/briefcase/commands/convert.py
@@ -18,7 +18,7 @@ if sys.version_info >= (3, 11):  # pragma: no-cover-if-lt-py311
 else:  # pragma: no-cover-if-gte-py311
     import tomli as tomllib
 
-from briefcase.config import make_class_name
+from briefcase.config import make_class_name, validate_url
 from briefcase.exceptions import BriefcaseCommandError
 
 
@@ -299,7 +299,7 @@ class ConvertCommand(NewCommand):
                 ),
                 variable="application URL",
                 default=default,
-                validator=self.validate_url,
+                validator=validate_url,
                 override_value=override_value,
             )
 
@@ -321,7 +321,7 @@ class ConvertCommand(NewCommand):
                 intro="What website URL do you want to use for the application?",
                 variable="application URL",
                 default=self.make_project_url("com.example", app_name),
-                validator=self.validate_url,
+                validator=validate_url,
             )
 
         return url
diff --git a/src/briefcase/commands/new.py b/src/briefcase/commands/new.py
index 48da0da5..c74ab79a 100644
--- a/src/briefcase/commands/new.py
+++ b/src/briefcase/commands/new.py
@@ -7,7 +7,6 @@ from collections import OrderedDict
 from collections.abc import Sequence
 from email.utils import parseaddr
 from typing import Iterable
-from urllib.parse import urlparse
 
 if sys.version_info >= (3, 10):  # pragma: no-cover-if-lt-py310
     from importlib.metadata import entry_points
@@ -21,6 +20,7 @@ from briefcase.config import (
     is_valid_app_name,
     is_valid_bundle_identifier,
     make_class_name,
+    validate_url,
 )
 from briefcase.console import MAX_TEXT_WIDTH
 from briefcase.console import select_option as _select_option
@@ -300,20 +300,6 @@ class NewCommand(BaseCommand):
         """
         return f"https://{self.make_domain(bundle)}/{app_name}"
 
-    def validate_url(self, candidate):
-        """Determine if the URL is valid.
-
-        :param candidate: The candidate URL
-        :returns: True. If there are any validation problems, raises ValueError with a
-            diagnostic message.
-        """
-        result = urlparse(candidate)
-        if not all([result.scheme, result.netloc]):
-            raise ValueError("Not a valid URL!")
-        if result.scheme not in {"http", "https"}:
-            raise ValueError("Not a valid website URL!")
-        return True
-
     def prompt_divider(self, title: str = ""):
         """Writes a divider with an optional title."""
         title = f"-- {title} " if title else ""
@@ -576,7 +562,7 @@ class NewCommand(BaseCommand):
             ),
             variable="application URL",
             default=self.make_project_url(bundle, app_name),
-            validator=self.validate_url,
+            validator=validate_url,
             override_value=project_overrides.pop("url", None),
         )
         project_license = self.input_license(
diff --git a/src/briefcase/config.py b/src/briefcase/config.py
index 14cc2ac0..92f1b75c 100644
--- a/src/briefcase/config.py
+++ b/src/briefcase/config.py
@@ -4,6 +4,7 @@ import re
 import sys
 import unicodedata
 from types import SimpleNamespace
+from urllib.parse import urlparse
 
 if sys.version_info >= (3, 11):  # pragma: no-cover-if-lt-py311
     import tomllib
@@ -83,6 +84,68 @@ def make_class_name(formal_name):
     return class_name
 
 
+def validate_url(candidate):
+    """Determine if the URL is valid.
+
+    :param candidate: The candidate URL
+    :returns: True. If there are any validation problems, raises ValueError with a
+        diagnostic message.
+    """
+    result = urlparse(candidate)
+    if not all([result.scheme, result.netloc]):
+        raise ValueError("Not a valid URL!")
+    if result.scheme not in {"http", "https"}:
+        raise ValueError("Not a valid website URL!")
+    return True
+
+
+def validate_document_type_config(document_type_id, document_type):
+
+    try:
+        if not (
+            isinstance(document_type["extension"], str)
+            and document_type["extension"].isalnum()
+        ):
+            raise BriefcaseConfigError(
+                f"The extension provided for document type {document_type_id!r} is not alphanumeric."
+            )
+    except KeyError:
+        raise BriefcaseConfigError(
+            f"Document type {document_type_id!r} does not provide an extension."
+        )
+
+    try:
+        if not isinstance(document_type["icon"], str):
+            raise BriefcaseConfigError(
+                f"The icon definition associated with document type {document_type_id!r} is not a string."
+            )
+    except KeyError:
+        raise BriefcaseConfigError(
+            f"Document type {document_type_id!r} does not define an icon."
+        )
+
+    try:
+        if not isinstance(document_type["description"], str):
+            raise BriefcaseConfigError(
+                f"The description associated with document type {document_type_id!r} is not a string."
+            )
+    except KeyError:
+        raise BriefcaseConfigError(
+            f"Document type {document_type_id!r} does not provide a description."
+        )
+
+    try:
+        validate_url(document_type["url"])
+    except KeyError:
+        raise BriefcaseConfigError(
+            f"Document type {document_type_id!r} does not provide a URL."
+        )
+    except ValueError as e:
+        raise BriefcaseConfigError(
+            f"The URL associated with document type {document_type_id!r} is invalid: {e}"
+        )
+
+
 VALID_BUNDLE_RE = re.compile(r"[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$")
 
 
@@ -247,6 +310,9 @@ class AppConfig(BaseConfig):
                 "'switch', or 'while')."
             )
 
+        for document_type_id, document_type in self.document_types.items():
+            validate_document_type_config(document_type_id, document_type)
+
         # Version number is PEP440 compliant:
         if not is_pep440_canonical_version(self.version):
             raise BriefcaseConfigError(


Test patch:
diff --git a/tests/commands/convert/test_input_url.py b/tests/commands/convert/test_input_url.py
index 05fe983f..a0a77e59 100644
--- a/tests/commands/convert/test_input_url.py
+++ b/tests/commands/convert/test_input_url.py
@@ -1,5 +1,7 @@
 from unittest.mock import MagicMock
 
+from briefcase.config import validate_url
+
 
 def test_multiple_pep621_options(convert_command, monkeypatch):
     mock_select_option = MagicMock()
@@ -64,7 +66,7 @@ def test_multiple_pep621_options_select_other(convert_command, monkeypatch):
         intro="What website URL do you want to use for the application?",
         variable="application URL",
         default="https://example.com/some-name",
-        validator=convert_command.validate_url,
+        validator=validate_url,
     )
 
 
@@ -81,7 +83,7 @@ def test_no_pep621_options(convert_command, monkeypatch):
         ),
         variable="application URL",
         default=default,
-        validator=convert_command.validate_url,
+        validator=validate_url,
         override_value=None,
     )
 
diff --git a/tests/commands/create/test_generate_app_template.py b/tests/commands/create/test_generate_app_template.py
index 9a4a508f..9e5c9c28 100644
--- a/tests/commands/create/test_generate_app_template.py
+++ b/tests/commands/create/test_generate_app_template.py
@@ -11,6 +11,7 @@ from git import exc as git_exceptions
 import briefcase
 from briefcase.exceptions import (
     BriefcaseCommandError,
+    BriefcaseConfigError,
     NetworkFailure,
     TemplateUnsupportedVersion,
 )
@@ -722,3 +723,25 @@ def test_x_permissions(
         extra_context=full_context,
         default_config={"replay_dir": str(tmp_path / "data/templates/.replay")},
     )
+
+
+def test_cookiecutter_undefined_variable_in_template(
+    myapp,
+    create_command,
+):
+    """If the cookiecutter template has an undefined variable, a breifcase configuration
+    error is raised."""
+
+    cookiecutter_exception_message = "undefined document type"
+    create_command.tools.cookiecutter.side_effect = (
+        cookiecutter_exceptions.UndefinedVariableInTemplate(
+            cookiecutter_exception_message, "error", "context"
+        )
+    )
+
+    with pytest.raises(
+        BriefcaseConfigError,
+        match=f"Briefcase configuration error: {cookiecutter_exception_message}",
+    ):
+        # Generating the template with an undefined cookiecutter variable generates an error
+        create_command.generate_app_template(myapp)
diff --git a/tests/commands/create/test_install_app_resources.py b/tests/commands/create/test_install_app_resources.py
index 43d6de01..d86eb60c 100644
--- a/tests/commands/create/test_install_app_resources.py
+++ b/tests/commands/create/test_install_app_resources.py
@@ -254,9 +254,17 @@ def test_doctype_icon_target(create_command, tmp_path):
         description="This is a simple app",
         sources=["src/my_app"],
         document_type={
-            "mydoc": {"icon": "images/mydoc-icon"},
+            "mydoc": {
+                "icon": "images/mydoc-icon",
+                "description": "mydoc image",
+                "url": "https://testexample.com",
+                "extension": "mydoc",
+            },
             "other": {
                 "icon": "images/other-icon",
+                "description": "other image",
+                "url": "https://othertestexample.com",
+                "extension": "other",
             },
         },
         license={"file": "LICENSE"},
diff --git a/tests/commands/new/test_make_project_url.py b/tests/commands/new/test_make_project_url.py
index b70e3e11..831fc334 100644
--- a/tests/commands/new/test_make_project_url.py
+++ b/tests/commands/new/test_make_project_url.py
@@ -1,5 +1,7 @@
 import pytest
 
+from briefcase.config import validate_url
+
 
 @pytest.mark.parametrize(
     "app_name, bundle, candidate",
@@ -14,4 +16,4 @@ def test_make_project_url(new_command, app_name, bundle, candidate):
     url = new_command.make_project_url(bundle, app_name)
     assert url == candidate
     # Double check - the app name passes the validity check.
-    assert new_command.validate_url(url)
+    assert validate_url(url)
diff --git a/tests/config/test_AppConfig.py b/tests/config/test_AppConfig.py
index dfd5f041..6fa8b8e7 100644
--- a/tests/config/test_AppConfig.py
+++ b/tests/config/test_AppConfig.py
@@ -56,8 +56,10 @@ def test_extra_attrs():
         requires=["first", "second", "third"],
         document_type={
             "document": {
+                "icon": "icon",
                 "extension": "doc",
                 "description": "A document",
+                "url": "https://testurl.com",
             }
         },
         first="value 1",
@@ -78,8 +80,10 @@ def test_extra_attrs():
     assert config.class_name == "MyApp"
     assert config.document_types == {
         "document": {
+            "icon": "icon",
             "extension": "doc",
             "description": "A document",
+            "url": "https://testurl.com",
         }
     }
 
diff --git a/tests/config/test_document_type_config.py b/tests/config/test_document_type_config.py
new file mode 100644
index 00000000..84ec6449
--- /dev/null
+++ b/tests/config/test_document_type_config.py
@@ -0,0 +1,162 @@
+import pytest
+
+from briefcase.config import validate_document_type_config
+from briefcase.exceptions import BriefcaseConfigError
+
+
+@pytest.fixture
+def valid_document():
+    return {
+        "icon": "icon",
+        "description": "description",
+        "url": "https://testurl.com",
+        "extension": "ext",
+    }
+
+
+@pytest.mark.parametrize(
+    "valid_document",
+    [
+        {
+            "icon": "icon",
+            "description": "description",
+            "url": "https://testurl.com",
+            "extension": "ext",
+        },
+        # Extension doesn't need to match document type
+        {
+            "icon": "icon 2",
+            "description": "this is a descrpition",
+            "url": "http://testurl.com",
+            "extension": "png",
+        },
+    ],
+)
+def test_document_type_config(valid_document):
+    """Valid document types don't raise an exception when validated."""
+    validate_document_type_config("ext", valid_document)
+
+
+def test_validate_document_missing_field(valid_document):
+    """If a document type is missing an icon, an exception is raised."""
+    del valid_document["icon"]
+    # Failure raises an exception
+    with pytest.raises(
+        BriefcaseConfigError, match=r"Document type .* does not define an icon."
+    ):
+        validate_document_type_config("ext", valid_document)
+
+
+@pytest.mark.parametrize(
+    "invalid_icon",
+    [
+        1,
+        None,
+        False,
+        True,
+    ],
+)
+def test_validate_document_invalid_icon(invalid_icon, valid_document):
+    """Invalid icon values raise an error when validating document types."""
+    valid_document["icon"] = invalid_icon
+    # Failure raises an exception
+    with pytest.raises(
+        BriefcaseConfigError,
+        match=r"The icon definition associated with document type .* is not a string.",
+    ):
+        validate_document_type_config("ext", valid_document)
+
+
+def test_validate_document_missing_description(valid_document):
+    """If a document type is missing a description, an exception is raised."""
+    del valid_document["description"]
+    # Failure raises an exception
+    with pytest.raises(
+        BriefcaseConfigError, match=r"Document type .* does not provide a description."
+    ):
+        validate_document_type_config("ext", valid_document)
+
+
+@pytest.mark.parametrize(
+    "invalid_description",
+    [
+        1,
+        None,
+        False,
+        True,
+    ],
+)
+def test_validate_document_invalid_description(invalid_description, valid_document):
+    """Invalid description values raise an error when validating document types."""
+
+    valid_document["description"] = invalid_description
+    # Failure raises an exception
+    with pytest.raises(
+        BriefcaseConfigError,
+        match=r"The description associated with document type .* is not a string.",
+    ):
+        validate_document_type_config("ext", valid_document)
+
+
+def test_validate_document_missing_url(valid_document):
+    """If a document type is missing a URL, an exception is raised."""
+
+    del valid_document["url"]
+    # Failure raises an exception
+    with pytest.raises(
+        BriefcaseConfigError, match=r"Document type .* does not provide a URL."
+    ):
+        validate_document_type_config("ext", valid_document)
+
+
+@pytest.mark.parametrize(
+    "invalid_url",
+    [
+        False,
+        None,
+        "test.com",
+        "fake",
+    ],
+)
+def test_validate_document_invalid_url(invalid_url, valid_document):
+    """Invalid URL values raise an error when validating document types."""
+
+    valid_document["url"] = invalid_url
+    # Failure raises an exception
+    with pytest.raises(
+        BriefcaseConfigError,
+        match=r"The URL associated with document type .* is invalid: Not a valid URL!",
+    ):
+        validate_document_type_config("ext", valid_document)
+
+
+def test_validate_document_missing_extension(valid_document):
+    """If a document type is missing an extension, an exception is raised."""
+
+    del valid_document["extension"]
+    # Failure raises an exception
+    with pytest.raises(
+        BriefcaseConfigError, match=r"Document type .* does not provide an extension."
+    ):
+        validate_document_type_config("ext", valid_document)
+
+
+@pytest.mark.parametrize(
+    "invalid_extension",
+    [
+        1,
+        "..",
+        ".-.",
+        False,
+        None,
+    ],
+)
+def test_validate_document_invalid_extension(invalid_extension, valid_document):
+    """Invalid extension values raise an error when validating document types."""
+    valid_document["extension"] = invalid_extension
+    # Failure raises an exception
+    with pytest.raises(
+        BriefcaseConfigError,
+        match=r"The extension provided for document type .* is not alphanumeric.",
+    ):
+        validate_document_type_config("ext", valid_document)
diff --git a/tests/commands/new/test_validate_url.py b/tests/config/test_validate_url.py
similarity index 72%
rename from tests/commands/new/test_validate_url.py
rename to tests/config/test_validate_url.py
index b1fb1d05..a034e03a 100644
--- a/tests/commands/new/test_validate_url.py
+++ b/tests/config/test_validate_url.py
@@ -1,5 +1,7 @@
 import pytest
 
+from briefcase.config import validate_url
+
 
 @pytest.mark.parametrize(
     "url",
@@ -7,9 +9,9 @@ import pytest
         "https://example.com",
     ],
 )
-def test_valid_url(new_command, url):
+def test_valid_url(url):
     """Test that valid URLs are accepted."""
-    assert new_command.validate_url(url)
+    assert validate_url(url)
 
 
 @pytest.mark.parametrize(
@@ -20,7 +22,7 @@ def test_valid_url(new_command, url):
         "gopher://example.com",  # URL, but not a webpage.
     ],
 )
-def test_invalid_url(new_command, url):
+def test_invalid_url(url):
     """Test that invalid URLs are rejected."""
     with pytest.raises(ValueError):
-        new_command.validate_url(url)
+        validate_url(url)