Author: jorton
Date: Thu Aug 13 15:14:58 2026
New Revision: 1937103
Log:
test/modules/aaa: Add a pytest suite for mod_auth_digest.
Assisted-by: Claude Sonnet 5 <[email protected]>
GitHub: PR #705
Added:
httpd/httpd/trunk/test/modules/aaa/
httpd/httpd/trunk/test/modules/aaa/conftest.py
httpd/httpd/trunk/test/modules/aaa/digest_client.py
httpd/httpd/trunk/test/modules/aaa/env.py
httpd/httpd/trunk/test/modules/aaa/htdocs/
httpd/httpd/trunk/test/modules/aaa/htdocs/digest/
httpd/httpd/trunk/test/modules/aaa/htdocs/digest/default/
httpd/httpd/trunk/test/modules/aaa/htdocs/digest/default/secret.txt
httpd/httpd/trunk/test/modules/aaa/htdocs/digest/domain/
httpd/httpd/trunk/test/modules/aaa/htdocs/digest/domain/nested/
httpd/httpd/trunk/test/modules/aaa/htdocs/digest/domain/nested/secret.txt
httpd/httpd/trunk/test/modules/aaa/htdocs/digest/domain/secret.txt
httpd/httpd/trunk/test/modules/aaa/htdocs/digest/nccheck/
httpd/httpd/trunk/test/modules/aaa/htdocs/digest/nccheck/secret.txt
httpd/httpd/trunk/test/modules/aaa/htdocs/digest/neverexpire/
httpd/httpd/trunk/test/modules/aaa/htdocs/digest/neverexpire/secret.txt
httpd/httpd/trunk/test/modules/aaa/htdocs/digest/noprovider/
httpd/httpd/trunk/test/modules/aaa/htdocs/digest/noprovider/secret.txt
httpd/httpd/trunk/test/modules/aaa/htdocs/digest/onetime/
httpd/httpd/trunk/test/modules/aaa/htdocs/digest/onetime/secret.txt
httpd/httpd/trunk/test/modules/aaa/htdocs/digest/shortlife/
httpd/httpd/trunk/test/modules/aaa/htdocs/digest/shortlife/secret.txt
httpd/httpd/trunk/test/modules/aaa/test_001_challenge_response.py
httpd/httpd/trunk/test/modules/aaa/test_002_nonce.py
httpd/httpd/trunk/test/modules/aaa/test_003_nccheck.py
httpd/httpd/trunk/test/modules/aaa/test_004_domain.py
httpd/httpd/trunk/test/modules/aaa/test_005_provider.py
httpd/httpd/trunk/test/modules/aaa/test_006_config_errors.py
Added: httpd/httpd/trunk/test/modules/aaa/conftest.py
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/conftest.py Thu Aug 13 15:14:58 2026 (r1937103)
@@ -0,0 +1,87 @@
+import logging
+import os
+import sys
+
+import pytest
+
+from .env import AAATestEnv
+from pyhttpd.conf import HttpdConf
+
+sys.path.append(os.path.join(os.path.dirname(__file__), '../..'))
+
+
+def pytest_report_header(config, start_path):
+ env = AAATestEnv()
+ return f"mod_auth_digest [apache: {env.get_httpd_version()}, mpm: {env.mpm_module}, {env.prefix}]"
+
+
+def _digest_dir(docs, path, extra_lines):
+ lines = [
+ f'<Directory "{docs}/digest/{path}">',
+ ' AuthType Digest',
+ f' AuthName "{AAATestEnv.REALM}"',
+ ]
+ lines.extend(f" {l}" for l in extra_lines)
+ lines.append(' Require valid-user')
+ lines.append('</Directory>')
+ return lines
+
+
[email protected](scope="package")
+def env(pytestconfig) -> AAATestEnv:
+ level = logging.INFO
+ console = logging.StreamHandler()
+ console.setLevel(level)
+ console.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
+ logging.getLogger('').addHandler(console)
+ logging.getLogger('').setLevel(level=level)
+ env = AAATestEnv(pytestconfig=pytestconfig)
+ env.setup_httpd()
+ env.apache_access_log_clear()
+ env.httpd_error_log.clear_log()
+
+ docs = env.server_docs_dir
+ pwfile = env.digest_pwfile
+ conf = HttpdConf(env)
+ conf.add(_digest_dir(docs, "default", [
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{pwfile}"',
+ ]))
+ conf.add(_digest_dir(docs, "nccheck", [
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{pwfile}"',
+ 'AuthDigestNcCheck On',
+ ]))
+ conf.add(_digest_dir(docs, "shortlife", [
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{pwfile}"',
+ 'AuthDigestNonceLifetime 2',
+ ]))
+ conf.add(_digest_dir(docs, "neverexpire", [
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{pwfile}"',
+ 'AuthDigestNonceLifetime -1',
+ ]))
+ conf.add(_digest_dir(docs, "onetime", [
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{pwfile}"',
+ 'AuthDigestNonceLifetime 0',
+ ]))
+ conf.add(_digest_dir(docs, "domain", [
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{pwfile}"',
+ 'AuthDigestDomain "/digest/domain/" "https://mirror.example.org/other/"',
+ ]))
+ conf.add(_digest_dir(docs, "noprovider", [
+ # AuthDigestProvider intentionally omitted: falls back to "file".
+ f'AuthUserFile "{pwfile}"',
+ ]))
+ conf.install()
+ assert env.apache_restart() == 0
+ return env
+
+
[email protected](autouse=True, scope="package")
+def _stop_package_scope(env):
+ yield
+ assert env.apache_stop() == 0
Added: httpd/httpd/trunk/test/modules/aaa/digest_client.py
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/digest_client.py Thu Aug 13 15:14:58 2026 (r1937103)
@@ -0,0 +1,134 @@
+"""Minimal hand-rolled RFC 2617 Digest auth client.
+
+curl's own `--digest` handles the challenge/response handshake transparently,
+which is no good for testing edge cases (tampered nonces, replayed
+nonce-counts, wrong realms, bad algorithm tokens, ...). This module lets
+tests parse a WWW-Authenticate challenge, compute the expected response by
+hand, and build a (possibly deliberately broken) Authorization header.
+
+mod_auth_digest here only implements qop="auth" (see modules/aaa/mod_auth_digest.c
+Open Issues: "MD5-sess and auth-int are not yet implemented"), so this client
+only implements the qop=auth request-digest/response-auth formulas from
+RFC 2617 section 3.2.2.
+"""
+
+import hashlib
+import re
+from dataclasses import dataclass
+from typing import Dict, List, Optional
+
+_PARAM_RE = re.compile(r'(\w+)=(?:"([^"]*)"|([^\s,]+))\s*,?\s*')
+
+
+def _md5hex(s: str) -> str:
+ return hashlib.md5(s.encode('utf-8')).hexdigest()
+
+
+def parse_params(value: str) -> Dict[str, str]:
+ """Parse a comma-separated key=value / key="value" list, as used by
+ both WWW-Authenticate and Authentication-Info header values."""
+ params = {}
+ for m in _PARAM_RE.finditer(value):
+ key = m.group(1)
+ val = m.group(2) if m.group(2) is not None else m.group(3)
+ params[key.lower()] = val
+ return params
+
+
+@dataclass
+class DigestChallenge:
+ realm: Optional[str]
+ nonce: Optional[str]
+ algorithm: Optional[str] = None
+ opaque: Optional[str] = None
+ domain: Optional[str] = None
+ qop: Optional[str] = None
+ stale: bool = False
+ raw: str = ""
+
+ @staticmethod
+ def parse(www_authenticate: str) -> 'DigestChallenge':
+ assert www_authenticate.startswith("Digest "), \
+ f"not a Digest challenge: {www_authenticate}"
+ params = parse_params(www_authenticate[len("Digest "):])
+ return DigestChallenge(
+ realm=params.get('realm'),
+ nonce=params.get('nonce'),
+ algorithm=params.get('algorithm'),
+ opaque=params.get('opaque'),
+ domain=params.get('domain'),
+ qop=params.get('qop'),
+ stale=params.get('stale', '').lower() == 'true',
+ raw=www_authenticate,
+ )
+
+ def domain_list(self) -> List[str]:
+ return self.domain.split() if self.domain else []
+
+
+def ha1(username: str, realm: str, password: str) -> str:
+ return _md5hex(f"{username}:{realm}:{password}")
+
+
+def ha2(method: str, uri: str) -> str:
+ return _md5hex(f"{method}:{uri}")
+
+
+def request_digest(ha1_hex: str, nonce: str, nc: str, cnonce: str,
+ qop: str, ha2_hex: str) -> str:
+ return _md5hex(f"{ha1_hex}:{nonce}:{nc}:{cnonce}:{qop}:{ha2_hex}")
+
+
+def rspauth_digest(ha1_hex: str, nonce: str, nc: str, cnonce: str,
+ qop: str, uri: str) -> str:
+ """Authentication-Info's rspauth uses A2 = ':' + uri (no method)."""
+ ha2_hex = _md5hex(f":{uri}")
+ return _md5hex(f"{ha1_hex}:{nonce}:{nc}:{cnonce}:{qop}:{ha2_hex}")
+
+
+def build_authorization(username: str, challenge: DigestChallenge, password: str,
+ method: str, uri: str, nc: str = "00000001",
+ cnonce: str = "0a4f113b3c2e7a1d", qop: Optional[str] = "auth",
+ realm: Optional[str] = None, nonce_val: Optional[str] = None,
+ algorithm: Optional[str] = None, response: Optional[str] = None,
+ opaque: Optional[str] = None, include_opaque: bool = True,
+ include_qop_fields: bool = True, extra: Optional[List[str]] = None
+ ) -> str:
+ """Build a Digest Authorization header value.
+
+ By default this builds a *correct* response for the given challenge and
+ credentials. Any of realm=/nonce_val=/algorithm=/response=/opaque= can be
+ overridden to construct deliberately invalid headers, and qop=None with
+ include_qop_fields=False builds a legacy RFC 2069-style header (no qop,
+ cnonce, or nc) to prove that path is rejected.
+ """
+ eff_realm = challenge.realm if realm is None else realm
+ eff_nonce = challenge.nonce if nonce_val is None else nonce_val
+ if response is None:
+ h1 = ha1(username, eff_realm, password)
+ h2 = ha2(method, uri)
+ if qop:
+ response = request_digest(h1, eff_nonce, nc, cnonce, qop, h2)
+ else:
+ # legacy RFC 2069: MD5(HA1:nonce:HA2), no qop/cnonce/nc
+ response = _md5hex(f"{h1}:{eff_nonce}:{h2}")
+
+ parts = [
+ f'username="{username}"',
+ f'realm="{eff_realm}"',
+ f'nonce="{eff_nonce}"',
+ f'uri="{uri}"',
+ f'response="{response}"',
+ ]
+ if algorithm is not None:
+ parts.append(f'algorithm={algorithm}')
+ if qop and include_qop_fields:
+ parts.append(f'qop={qop}')
+ parts.append(f'nc={nc}')
+ parts.append(f'cnonce="{cnonce}"')
+ eff_opaque = challenge.opaque if (opaque is None and include_opaque) else opaque
+ if eff_opaque:
+ parts.append(f'opaque="{eff_opaque}"')
+ if extra:
+ parts.extend(extra)
+ return "Digest " + ", ".join(parts)
Added: httpd/httpd/trunk/test/modules/aaa/env.py
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/env.py Thu Aug 13 15:14:58 2026 (r1937103)
@@ -0,0 +1,79 @@
+import hashlib
+import inspect
+import logging
+import os
+from typing import List, Optional
+
+from pyhttpd.env import HttpdTestEnv, HttpdTestSetup
+from pyhttpd.result import ExecResult
+
+log = logging.getLogger(__name__)
+
+
+class AAATestSetup(HttpdTestSetup):
+
+ def __init__(self, env: 'HttpdTestEnv'):
+ super().__init__(env=env)
+ self.add_source_dir(os.path.dirname(inspect.getfile(AAATestSetup)))
+ self.add_modules(["auth_digest", "authn_file", "authn_core",
+ "authz_core", "authz_user"])
+
+
+class AAATestEnv(HttpdTestEnv):
+
+ REALM = "AAA Digest Realm"
+ DIGEST_USER = "digestuser"
+ DIGEST_PASSWORD = "digestpass2617"
+ DIGEST_USER2 = "otheruser"
+ DIGEST_PASSWORD2 = "otherpass2617"
+
+ def __init__(self, pytestconfig=None):
+ super().__init__(pytestconfig=pytestconfig)
+ self.add_httpd_log_modules(["auth_digest", "authn_file", "authz_core"])
+ self._digest_pwfile = os.path.join(self.server_dir, "digest.passwd")
+
+ def setup_httpd(self, setup: HttpdTestSetup = None):
+ super().setup_httpd(setup=AAATestSetup(env=self))
+ self._write_digest_pwfile()
+
+ def _write_digest_pwfile(self):
+ def ha1(user, password):
+ return hashlib.md5(
+ f"{user}:{self.REALM}:{password}".encode()).hexdigest()
+
+ with open(self._digest_pwfile, 'w') as fd:
+ fd.write(f"{self.DIGEST_USER}:{self.REALM}:"
+ f"{ha1(self.DIGEST_USER, self.DIGEST_PASSWORD)}\n")
+ fd.write(f"{self.DIGEST_USER2}:{self.REALM}:"
+ f"{ha1(self.DIGEST_USER2, self.DIGEST_PASSWORD2)}\n")
+
+ @property
+ def digest_pwfile(self) -> str:
+ return self._digest_pwfile
+
+ def configtest(self, directory_lines: List[str], extra_top_lines: Optional[List[str]] = None
+ ) -> ExecResult:
+ """Run `httpd -t` against a minimal, standalone config built from the
+ already-generated modules.conf plus `directory_lines` wrapped in a
+ <Directory> block over the shared docroot. Used to test directives
+ that are rejected at config-check time (e.g. AuthDigestQop values
+ other than 'auth') without touching the package's running server.
+ """
+ conf_path = os.path.join(self.gen_dir, "digest-configtest.conf")
+ modules_conf = os.path.join(self.server_conf_dir, "modules.conf")
+ lines = [
+ f'ServerRoot "{self.server_dir}"',
+ f'Include "{modules_conf}"',
+ f'DocumentRoot "{self.server_docs_dir}"',
+ f'Listen {self.http_port2}',
+ ]
+ if extra_top_lines:
+ lines.extend(extra_top_lines)
+ lines.append(f'<Directory "{self.server_docs_dir}">')
+ lines.extend(f" {l}" for l in directory_lines)
+ lines.append('</Directory>')
+ with open(conf_path, 'w') as fd:
+ fd.write('\n'.join(lines))
+ fd.write('\n')
+ httpd_bin = os.path.join(self.bin_dir, 'httpd')
+ return self.run([httpd_bin, '-t', '-f', conf_path])
Added: httpd/httpd/trunk/test/modules/aaa/htdocs/digest/default/secret.txt
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/htdocs/digest/default/secret.txt Thu Aug 13 15:14:58 2026 (r1937103)
@@ -0,0 +1 @@
+digest-default-secret
Added: httpd/httpd/trunk/test/modules/aaa/htdocs/digest/domain/nested/secret.txt
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/htdocs/digest/domain/nested/secret.txt Thu Aug 13 15:14:58 2026 (r1937103)
@@ -0,0 +1 @@
+digest-domain-nested-secret
Added: httpd/httpd/trunk/test/modules/aaa/htdocs/digest/domain/secret.txt
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/htdocs/digest/domain/secret.txt Thu Aug 13 15:14:58 2026 (r1937103)
@@ -0,0 +1 @@
+digest-domain-secret
Added: httpd/httpd/trunk/test/modules/aaa/htdocs/digest/nccheck/secret.txt
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/htdocs/digest/nccheck/secret.txt Thu Aug 13 15:14:58 2026 (r1937103)
@@ -0,0 +1 @@
+digest-nccheck-secret
Added: httpd/httpd/trunk/test/modules/aaa/htdocs/digest/neverexpire/secret.txt
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/htdocs/digest/neverexpire/secret.txt Thu Aug 13 15:14:58 2026 (r1937103)
@@ -0,0 +1 @@
+digest-neverexpire-secret
Added: httpd/httpd/trunk/test/modules/aaa/htdocs/digest/noprovider/secret.txt
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/htdocs/digest/noprovider/secret.txt Thu Aug 13 15:14:58 2026 (r1937103)
@@ -0,0 +1 @@
+digest-noprovider-secret
Added: httpd/httpd/trunk/test/modules/aaa/htdocs/digest/onetime/secret.txt
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/htdocs/digest/onetime/secret.txt Thu Aug 13 15:14:58 2026 (r1937103)
@@ -0,0 +1 @@
+digest-onetime-secret
Added: httpd/httpd/trunk/test/modules/aaa/htdocs/digest/shortlife/secret.txt
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/htdocs/digest/shortlife/secret.txt Thu Aug 13 15:14:58 2026 (r1937103)
@@ -0,0 +1 @@
+digest-shortlife-secret
Added: httpd/httpd/trunk/test/modules/aaa/test_001_challenge_response.py
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/test_001_challenge_response.py Thu Aug 13 15:14:58 2026 (r1937103)
@@ -0,0 +1,180 @@
+"""RFC 2617 Digest challenge/response scenarios against mod_auth_digest's
+default configuration (AuthDigestProvider file, AuthDigestQop auth (the only
+supported value), AuthDigestNonceLifetime 300, no AuthDigestDomain).
+"""
+
+from . import digest_client as dc
+from .env import AAATestEnv
+
+
+class TestDigestChallengeResponse:
+
+ def url(self, env, path="secret.txt", location="default"):
+ return env.mkurl("http", "aaa", f"/digest/{location}/{path}")
+
+ def challenge(self, env, location="default"):
+ r = env.curl_get(self.url(env, location=location))
+ assert r.response["status"] == 401
+ return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ def test_digest_001_no_credentials(self, env):
+ # No Authorization header at all -> 401 with a well-formed challenge.
+ r = env.curl_get(self.url(env))
+ assert r.response["status"] == 401
+ auth = r.response["header"]["www-authenticate"]
+ challenge = dc.DigestChallenge.parse(auth)
+ assert challenge.realm == AAATestEnv.REALM
+ assert challenge.algorithm == "MD5"
+ assert challenge.qop == "auth"
+ assert challenge.stale is False
+ # no AuthDigestDomain configured for this Location -> no domain=
+ assert challenge.domain is None
+ # nonce-count checking is off and lifetime isn't 0 here, so the
+ # server has no reason to track this client -> no opaque=
+ assert challenge.opaque is None
+
+ def test_digest_002_success(self, env):
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/default/secret.txt")
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 200
+ assert r.response["body"].decode() == "digest-default-secret\n"
+
+ def test_digest_003_rspauth(self, env):
+ # Authentication-Info's rspauth= must match what we independently
+ # compute from the same HA1 -- proves the server round-trips the
+ # session parameters (nonce/nc/cnonce/qop) correctly.
+ challenge = self.challenge(env)
+ nc = "00000001"
+ cnonce = "test-cnonce-rspauth"
+ uri = "/digest/default/secret.txt"
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=uri, nc=nc, cnonce=cnonce)
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 200
+ ai = dc.parse_params(r.response["header"]["authentication-info"])
+ h1 = dc.ha1(AAATestEnv.DIGEST_USER, challenge.realm, AAATestEnv.DIGEST_PASSWORD)
+ expected = dc.rspauth_digest(h1, challenge.nonce, nc, cnonce, "auth", uri)
+ assert ai["rspauth"] == expected
+ assert ai["qop"] == "auth"
+ assert ai["nc"] == nc
+ assert ai["cnonce"] == cnonce
+
+ def test_digest_004_wrong_password(self, env):
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, "not-the-password",
+ method="GET", uri="/digest/default/secret.txt")
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01794"])
+
+ def test_digest_005_unknown_user(self, env):
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ "no-such-user", challenge, "whatever",
+ method="GET", uri="/digest/default/secret.txt")
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01790"])
+
+ def test_digest_006_second_user(self, env):
+ # a distinct user in the same password file also works
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER2, challenge, AAATestEnv.DIGEST_PASSWORD2,
+ method="GET", uri="/digest/default/secret.txt")
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 200
+
+ def test_digest_007_wrong_realm(self, env):
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/default/secret.txt",
+ realm="Some Other Realm")
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01788"])
+
+ def test_digest_008_bad_algorithm_token(self, env):
+ # a client claiming an algorithm other than MD5 is rejected outright,
+ # even though the response hash below is computed correctly for MD5.
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/default/secret.txt",
+ algorithm="MD5-sess")
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01789"])
+
+ def test_digest_009_legacy_no_qop_rejected(self, env):
+ # RFC 2069-style digest (no qop/cnonce/nc) is syntactically valid but
+ # explicitly no longer supported by this module.
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/default/secret.txt",
+ qop=None, include_qop_fields=False)
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH10560"])
+
+ def test_digest_010_malformed_header_missing_field(self, env):
+ # missing "uri" entirely -> header is syntactically INVALID, so the
+ # server issues a fresh (non-stale) challenge rather than evaluating
+ # the (nonexistent) response hash.
+ challenge = self.challenge(env)
+ h1 = dc.ha1(AAATestEnv.DIGEST_USER, challenge.realm, AAATestEnv.DIGEST_PASSWORD)
+ auth = ('Digest username="digestuser", '
+ f'realm="{challenge.realm}", nonce="{challenge.nonce}", '
+ f'response="{h1}", qop=auth, nc=00000001, cnonce="x"')
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert new_challenge.stale is False
+ env.httpd_error_log.ignore_recent(lognos=["AH01782"])
+
+ def test_digest_011_wrong_scheme(self, env):
+ r = env.curl_get(self.url(env), options=[
+ "-H", "Authorization: Basic ZGlnZXN0dXNlcjpkaWdlc3RwYXNz"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01781"])
+
+ def test_digest_012_uri_mismatch(self, env):
+ # The Authorization uri= must match the actual request-target; a
+ # self-consistent response computed for a *different* uri than the
+ # one actually requested is rejected as a bad request, before the
+ # hash is even checked.
+ challenge = self.challenge(env)
+ other_uri = "/digest/default/other-secret.txt"
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=other_uri)
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 400
+ env.httpd_error_log.ignore_recent(lognos=["AH01786"])
+
+ def test_digest_013_invalid_opaque(self, env):
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/default/secret.txt",
+ opaque="not-a-hex-number")
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01787"])
+
+ def test_digest_014_tampered_response_hash(self, env):
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/default/secret.txt",
+ response="0" * 32)
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01794"])
Added: httpd/httpd/trunk/test/modules/aaa/test_002_nonce.py
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/test_002_nonce.py Thu Aug 13 15:14:58 2026 (r1937103)
@@ -0,0 +1,129 @@
+"""Nonce lifecycle scenarios: tampered nonces, AuthDigestNonceLifetime
+expiry/reissue, a never-expiring nonce, and the one-time-nonce
+(AuthDigestNonceLifetime 0) case.
+"""
+
+import time
+
+from . import digest_client as dc
+from .env import AAATestEnv
+
+
+class TestDigestNonce:
+
+ def url(self, env, location, path="secret.txt"):
+ return env.mkurl("http", "aaa", f"/digest/{location}/{path}")
+
+ def challenge(self, env, location):
+ r = env.curl_get(self.url(env, location))
+ assert r.response["status"] == 401
+ return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ def authenticate(self, env, location, challenge, nc="00000001",
+ cnonce="nonce-test-cnonce", uri=None):
+ uri = uri or f"/digest/{location}/secret.txt"
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=uri, nc=nc, cnonce=cnonce)
+ return env.curl_get(self.url(env, location), options=["-H", f"Authorization: {auth}"])
+
+ def test_digest_020_tampered_nonce_is_stale(self, env):
+ challenge = self.challenge(env, "default")
+ # flip a character in the middle of the opaque nonce blob: it stays
+ # the right length but its embedded hash no longer verifies.
+ bad = list(challenge.nonce)
+ mid = len(bad) // 2
+ bad[mid] = 'x' if bad[mid] != 'x' else 'y'
+ challenge.nonce = ''.join(bad)
+ r = self.authenticate(env, "default", challenge)
+ assert r.response["status"] == 401
+ new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert new_challenge.stale is True
+ env.httpd_error_log.ignore_recent(lognos=["AH01776"])
+
+ def test_digest_021_garbage_nonce_hash_is_stale(self, env):
+ # A nonce must still look like "b64(time)+sha1hex(hash)" (VALID_NONCE
+ # in mod_auth_digest.c checks length and the '=' padding boundary) to
+ # even be considered for a hash check; something that doesn't match
+ # that shape is instead rejected as a malformed header (see
+ # test_digest_010). Here we keep the genuine time-prefix (so the
+ # shape is valid) but replace the whole hash suffix with garbage, to
+ # hit check_nonce()'s "hash is not %s" path distinctly from
+ # test_digest_020's single-flipped-character tamper.
+ challenge = self.challenge(env, "default")
+ time_prefix = challenge.nonce[:-40]
+ challenge.nonce = time_prefix + ("f" * 40)
+ r = self.authenticate(env, "default", challenge)
+ assert r.response["status"] == 401
+ new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert new_challenge.stale is True
+ env.httpd_error_log.ignore_recent(lognos=["AH01776"])
+
+ def test_digest_022_short_lifetime_expires(self, env):
+ # AuthDigestNonceLifetime 2 for this location.
+ challenge = self.challenge(env, "shortlife")
+ r = self.authenticate(env, "shortlife", challenge)
+ assert r.response["status"] == 200
+
+ time.sleep(3)
+ # same nonce, now past its lifetime -> 401 stale=true
+ r = self.authenticate(env, "shortlife", challenge)
+ assert r.response["status"] == 401
+ stale_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert stale_challenge.stale is True
+ env.httpd_error_log.ignore_recent(lognos=["AH01776"])
+
+ # the fresh nonce from the stale challenge works again
+ r = self.authenticate(env, "shortlife", stale_challenge)
+ assert r.response["status"] == 200
+
+ def test_digest_023_never_expiring_nonce(self, env):
+ # AuthDigestNonceLifetime -1 for this location: no NcCheck is
+ # configured, so the identical Authorization line can simply be
+ # replayed after a delay and must still succeed both times.
+ challenge = self.challenge(env, "neverexpire")
+ r1 = self.authenticate(env, "neverexpire", challenge)
+ assert r1.response["status"] == 200
+
+ time.sleep(3)
+ r2 = self.authenticate(env, "neverexpire", challenge)
+ assert r2.response["status"] == 200
+
+ def test_digest_024_one_time_nonce_rejects_reuse(self, env):
+ # AuthDigestNonceLifetime 0: a successful request immediately
+ # supersedes its nonce (the tracked "last_nonce" moves on to the
+ # nextnonce from Authentication-Info), so replaying the very same
+ # nonce right afterwards must fail as stale. Each request against
+ # this client (success OR failure) advances the tracked nonce again,
+ # so this test does exactly one success followed by exactly one
+ # reuse -- no longer chain that would need to account for that.
+ challenge = self.challenge(env, "onetime")
+ assert challenge.opaque is not None, \
+ "one-time-nonce tracking requires an opaque to identify the client"
+
+ r1 = self.authenticate(env, "onetime", challenge)
+ assert r1.response["status"] == 200
+ ai1 = dc.parse_params(r1.response["header"]["authentication-info"])
+ assert "nextnonce" in ai1
+ assert ai1["nextnonce"] != challenge.nonce
+
+ # reusing the exact same (now superseded) nonce fails as stale
+ r2 = self.authenticate(env, "onetime", challenge)
+ assert r2.response["status"] == 401
+ stale_challenge = dc.DigestChallenge.parse(r2.response["header"]["www-authenticate"])
+ assert stale_challenge.stale is True
+ env.httpd_error_log.ignore_recent(lognos=["AH01776"])
+
+ def test_digest_025_one_time_nonce_chain_continues(self, env):
+ # Following the nextnonce handed out on a successful response lets
+ # the client keep authenticating, one hop at a time.
+ challenge = self.challenge(env, "onetime")
+ r1 = self.authenticate(env, "onetime", challenge)
+ assert r1.response["status"] == 200
+ ai1 = dc.parse_params(r1.response["header"]["authentication-info"])
+
+ challenge.nonce = ai1["nextnonce"]
+ r2 = self.authenticate(env, "onetime", challenge)
+ assert r2.response["status"] == 200
+ ai2 = dc.parse_params(r2.response["header"]["authentication-info"])
+ assert ai2["nextnonce"] != ai1["nextnonce"]
Added: httpd/httpd/trunk/test/modules/aaa/test_003_nccheck.py
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/test_003_nccheck.py Thu Aug 13 15:14:58 2026 (r1937103)
@@ -0,0 +1,99 @@
+"""AuthDigestNcCheck replay-detection scenarios.
+
+Note the actual semantics here are stricter than a sliding replay window:
+the server keeps its own count of authenticated requests seen for a client
+(incremented on *every* request carrying that client's opaque, whether or
+not it goes on to authenticate) and requires the client's nc to match it
+*exactly* -- so both replays of an old nc and skipping ahead are rejected.
+A failed nc check also resets the server's tracked count back to 0, as part
+of issuing a fresh challenge for the client (see note_digest_auth_failure()
+in mod_auth_digest.c: an existing, opaque-identified client always gets its
+nonce_count reset when a new challenge is generated for it, regardless of
+*why* the challenge is being reissued) -- so recovery after a rejected nc
+means starting the sequence over at 00000001, not continuing where the
+client left off.
+"""
+
+from . import digest_client as dc
+from .env import AAATestEnv
+
+
+class TestDigestNcCheck:
+
+ def url(self, env, location, path="secret.txt"):
+ return env.mkurl("http", "aaa", f"/digest/{location}/{path}")
+
+ def challenge(self, env, location):
+ r = env.curl_get(self.url(env, location))
+ assert r.response["status"] == 401
+ return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ def authenticate(self, env, location, challenge, nc, cnonce="ncc-test-cnonce",
+ include_opaque=True):
+ uri = f"/digest/{location}/secret.txt"
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=uri, nc=nc, cnonce=cnonce,
+ include_opaque=include_opaque)
+ return env.curl_get(self.url(env, location), options=["-H", f"Authorization: {auth}"])
+
+ def test_digest_030_nccheck_requires_opaque(self, env):
+ # with AuthDigestNcCheck on, the server cannot verify nc without
+ # having tracked this client via its opaque -- omitting the opaque
+ # therefore fails the check outright, even with nc=00000001.
+ challenge = self.challenge(env, "nccheck")
+ assert challenge.opaque is not None
+ r = self.authenticate(env, "nccheck", challenge, nc="00000001", include_opaque=False)
+ assert r.response["status"] == 401
+ new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert new_challenge.stale is False
+
+ def test_digest_031_nccheck_sequential_ok(self, env):
+ challenge = self.challenge(env, "nccheck")
+ r1 = self.authenticate(env, "nccheck", challenge, nc="00000001")
+ assert r1.response["status"] == 200
+ r2 = self.authenticate(env, "nccheck", challenge, nc="00000002")
+ assert r2.response["status"] == 200
+ r3 = self.authenticate(env, "nccheck", challenge, nc="00000003")
+ assert r3.response["status"] == 200
+
+ def test_digest_032_nccheck_replay_rejected(self, env):
+ challenge = self.challenge(env, "nccheck")
+ r1 = self.authenticate(env, "nccheck", challenge, nc="00000001")
+ assert r1.response["status"] == 200
+ r2 = self.authenticate(env, "nccheck", challenge, nc="00000002")
+ assert r2.response["status"] == 200
+
+ # replay an already-used nc -> rejected, and NOT reported as stale
+ # (this is a distinct failure mode from an invalid/expired nonce).
+ r3 = self.authenticate(env, "nccheck", challenge, nc="00000001")
+ assert r3.response["status"] == 401
+ new_challenge = dc.DigestChallenge.parse(r3.response["header"]["www-authenticate"])
+ assert new_challenge.stale is False
+ env.httpd_error_log.ignore_recent(lognos=["AH01774"])
+
+ # the rejected attempt reset the server's tracked count to 0 (a new
+ # challenge was issued for this client), so recovery restarts the
+ # sequence at 00000001 -- continuing from 00000003 would NOT work.
+ r4 = self.authenticate(env, "nccheck", challenge, nc="00000001")
+ assert r4.response["status"] == 200
+
+ def test_digest_033_nccheck_skip_ahead_rejected(self, env):
+ challenge = self.challenge(env, "nccheck")
+ r1 = self.authenticate(env, "nccheck", challenge, nc="00000001")
+ assert r1.response["status"] == 200
+
+ # skipping ahead is rejected too: nc must match exactly, not just
+ # be higher than what was last accepted.
+ r2 = self.authenticate(env, "nccheck", challenge, nc="00000009")
+ assert r2.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01774"])
+
+ def test_digest_034_no_nccheck_allows_replay(self, env):
+ # the "default" location has no AuthDigestNcCheck (Off by default),
+ # so replaying the exact same nc is not detected or rejected.
+ challenge = self.challenge(env, "default")
+ r1 = self.authenticate(env, "default", challenge, nc="00000001")
+ assert r1.response["status"] == 200
+ r2 = self.authenticate(env, "default", challenge, nc="00000001")
+ assert r2.response["status"] == 200
Added: httpd/httpd/trunk/test/modules/aaa/test_004_domain.py
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/test_004_domain.py Thu Aug 13 15:14:58 2026 (r1937103)
@@ -0,0 +1,56 @@
+"""AuthDigestDomain: presence, format, and inheritance of the domain=
+attribute in the WWW-Authenticate challenge.
+"""
+
+from . import digest_client as dc
+from .env import AAATestEnv
+
+
+class TestDigestDomain:
+
+ def url(self, env, path):
+ return env.mkurl("http", "aaa", path)
+
+ def test_digest_040_domain_attribute_present(self, env):
+ r = env.curl_get(self.url(env, "/digest/domain/secret.txt"))
+ assert r.response["status"] == 401
+ challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ # set_uri_list() (mod_auth_digest.c) builds a single quoted,
+ # space-separated list from the configured AuthDigestDomain URIs.
+ assert challenge.domain == "/digest/domain/ https://mirror.example.org/other/"
+ assert challenge.domain_list() == [
+ "/digest/domain/", "https://mirror.example.org/other/"]
+
+ def test_digest_041_no_domain_configured_omits_attribute(self, env):
+ r = env.curl_get(self.url(env, "/digest/default/secret.txt"))
+ assert r.response["status"] == 401
+ challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert challenge.domain is None
+
+ def test_digest_042_domain_location_still_authenticates(self, env):
+ r = env.curl_get(self.url(env, "/digest/domain/secret.txt"))
+ challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/domain/secret.txt")
+ r = env.curl_get(self.url(env, "/digest/domain/secret.txt"),
+ options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 200
+ assert r.response["body"].decode() == "digest-domain-secret\n"
+
+ def test_digest_043_domain_inherited_by_nested_path(self, env):
+ # AuthDigestDomain is set on /digest/domain/; a path nested below it
+ # inherits the same directory config (same realm/credentials/domain).
+ r = env.curl_get(self.url(env, "/digest/domain/nested/secret.txt"))
+ assert r.response["status"] == 401
+ challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert challenge.realm == AAATestEnv.REALM
+ assert challenge.domain == "/digest/domain/ https://mirror.example.org/other/"
+
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/domain/nested/secret.txt")
+ r = env.curl_get(self.url(env, "/digest/domain/nested/secret.txt"),
+ options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 200
+ assert r.response["body"].decode() == "digest-domain-nested-secret\n"
Added: httpd/httpd/trunk/test/modules/aaa/test_005_provider.py
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/test_005_provider.py Thu Aug 13 15:14:58 2026 (r1937103)
@@ -0,0 +1,37 @@
+"""AuthDigestProvider scenarios."""
+
+from . import digest_client as dc
+from .env import AAATestEnv
+
+
+class TestDigestProvider:
+
+ def url(self, env, path):
+ return env.mkurl("http", "aaa", path)
+
+ def test_digest_050_omitted_provider_defaults_to_file(self, env):
+ # /digest/noprovider/ has no AuthDigestProvider directive at all;
+ # mod_auth_digest falls back to the "file" provider (mod_authn_file)
+ # by default (see get_hash() / AUTHN_DEFAULT_PROVIDER in mod_auth.h).
+ path = "/digest/noprovider/secret.txt"
+ r = env.curl_get(self.url(env, path))
+ assert r.response["status"] == 401
+ challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=path)
+ r = env.curl_get(self.url(env, path), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 200
+ assert r.response["body"].decode() == "digest-noprovider-secret\n"
+
+ def test_digest_051_unknown_provider_rejected_at_config_time(self, env):
+ r = env.configtest([
+ 'AuthType Digest',
+ f'AuthName "{AAATestEnv.REALM}"',
+ 'AuthDigestProvider no-such-provider',
+ f'AuthUserFile "{env.digest_pwfile}"',
+ 'Require valid-user',
+ ])
+ assert r.exit_code != 0
+ assert "Unknown Authn provider" in r.stderr
Added: httpd/httpd/trunk/test/modules/aaa/test_006_config_errors.py
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/test_006_config_errors.py Thu Aug 13 15:14:58 2026 (r1937103)
@@ -0,0 +1,86 @@
+"""Config-time validation for directives whose *documented* syntax (see
+docs/manual/mod/mod_auth_digest.xml) is broader than what this build's
+mod_auth_digest.c actually implements: AuthDigestQop only accepts "auth"
+(qop=none/auth-int are rejected -- the "Open Issues" comment in the source
+notes MD5-sess and auth-int were removed as incomplete), AuthDigestAlgorithm
+only accepts "MD5", and AuthDigestShmemSize enforces a minimum size. These
+are all checked with `httpd -t` against a throwaway config so the shared
+package server is never disturbed.
+"""
+
+from .env import AAATestEnv
+
+
+class TestDigestConfigErrors:
+
+ def test_digest_060_qop_none_rejected(self, env):
+ r = env.configtest([
+ 'AuthType Digest',
+ f'AuthName "{AAATestEnv.REALM}"',
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{env.digest_pwfile}"',
+ 'AuthDigestQop none',
+ 'Require valid-user',
+ ])
+ assert r.exit_code != 0
+ assert "AuthDigestQop" in r.stderr
+
+ def test_digest_061_qop_auth_int_rejected(self, env):
+ r = env.configtest([
+ 'AuthType Digest',
+ f'AuthName "{AAATestEnv.REALM}"',
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{env.digest_pwfile}"',
+ 'AuthDigestQop auth-int',
+ 'Require valid-user',
+ ])
+ assert r.exit_code != 0
+ assert "AuthDigestQop" in r.stderr
+
+ def test_digest_062_qop_auth_accepted(self, env):
+ # the only value actually supported must still work.
+ r = env.configtest([
+ 'AuthType Digest',
+ f'AuthName "{AAATestEnv.REALM}"',
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{env.digest_pwfile}"',
+ 'AuthDigestQop auth',
+ 'Require valid-user',
+ ])
+ assert r.exit_code == 0
+
+ def test_digest_063_algorithm_md5_sess_rejected(self, env):
+ r = env.configtest([
+ 'AuthType Digest',
+ f'AuthName "{AAATestEnv.REALM}"',
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{env.digest_pwfile}"',
+ 'AuthDigestAlgorithm MD5-sess',
+ 'Require valid-user',
+ ])
+ assert r.exit_code != 0
+ assert "Unsupported algorithm" in r.stderr
+
+ def test_digest_064_algorithm_md5_accepted(self, env):
+ r = env.configtest([
+ 'AuthType Digest',
+ f'AuthName "{AAATestEnv.REALM}"',
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{env.digest_pwfile}"',
+ 'AuthDigestAlgorithm MD5',
+ 'Require valid-user',
+ ])
+ assert r.exit_code == 0
+
+ def test_digest_065_shmemsize_too_small_rejected(self, env):
+ r = env.configtest([], extra_top_lines=["AuthDigestShmemSize 10"])
+ assert r.exit_code != 0
+ assert "AuthDigestShmemSize" in r.stderr
+
+ def test_digest_066_shmemsize_valid_accepted(self, env):
+ r = env.configtest([], extra_top_lines=["AuthDigestShmemSize 1000"])
+ assert r.exit_code == 0
+
+ def test_digest_067_shmemsize_units_accepted(self, env):
+ r = env.configtest([], extra_top_lines=["AuthDigestShmemSize 64K"])
+ assert r.exit_code == 0
lmpx.com only provides a reader for public news (NNTP) servers. It is not
affiliated with the servers or forums shown here and is not responsible for
the content of articles, which is written by their respective authors.