summaryrefslogtreecommitdiffstats
path: root/test/modules/http2
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 15:01:30 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 15:01:30 +0000
commit6beeb1b708550be0d4a53b272283e17e5e35fe17 (patch)
tree1ce8673d4aaa948e5554000101f46536a1e4cc29 /test/modules/http2
parentInitial commit. (diff)
downloadapache2-6beeb1b708550be0d4a53b272283e17e5e35fe17.tar.xz
apache2-6beeb1b708550be0d4a53b272283e17e5e35fe17.zip
Adding upstream version 2.4.57.upstream/2.4.57
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'test/modules/http2')
-rw-r--r--test/modules/http2/.gitignore3
-rw-r--r--test/modules/http2/Makefile.in20
-rw-r--r--test/modules/http2/__init__.py0
-rw-r--r--test/modules/http2/conftest.py40
-rw-r--r--test/modules/http2/env.py168
-rw-r--r--test/modules/http2/htdocs/cgi/alive.json4
-rw-r--r--test/modules/http2/htdocs/cgi/echo.py16
-rw-r--r--test/modules/http2/htdocs/cgi/echohd.py37
-rw-r--r--test/modules/http2/htdocs/cgi/env.py56
-rw-r--r--test/modules/http2/htdocs/cgi/files/empty.txt0
-rw-r--r--test/modules/http2/htdocs/cgi/hecho.py63
-rw-r--r--test/modules/http2/htdocs/cgi/hello.py20
-rw-r--r--test/modules/http2/htdocs/cgi/mnot164.py31
-rw-r--r--test/modules/http2/htdocs/cgi/necho.py70
-rw-r--r--test/modules/http2/htdocs/cgi/upload.py78
-rw-r--r--test/modules/http2/htdocs/noh2/alive.json5
-rw-r--r--test/modules/http2/htdocs/noh2/index.html9
-rw-r--r--test/modules/http2/mod_h2test/mod_h2test.c527
-rw-r--r--test/modules/http2/mod_h2test/mod_h2test.h21
-rw-r--r--test/modules/http2/test_001_httpd_alive.py22
-rw-r--r--test/modules/http2/test_002_curl_basics.py71
-rw-r--r--test/modules/http2/test_003_get.py265
-rw-r--r--test/modules/http2/test_004_post.py219
-rw-r--r--test/modules/http2/test_005_files.py48
-rw-r--r--test/modules/http2/test_006_assets.py75
-rw-r--r--test/modules/http2/test_100_conn_reuse.py57
-rw-r--r--test/modules/http2/test_101_ssl_reneg.py130
-rw-r--r--test/modules/http2/test_102_require.py41
-rw-r--r--test/modules/http2/test_103_upgrade.py118
-rw-r--r--test/modules/http2/test_104_padding.py98
-rw-r--r--test/modules/http2/test_105_timeout.py149
-rw-r--r--test/modules/http2/test_106_shutdown.py69
-rw-r--r--test/modules/http2/test_200_header_invalid.py182
-rw-r--r--test/modules/http2/test_201_header_conditional.py70
-rw-r--r--test/modules/http2/test_202_trailer.py92
-rw-r--r--test/modules/http2/test_203_rfc9113.py56
-rw-r--r--test/modules/http2/test_300_interim.py40
-rw-r--r--test/modules/http2/test_400_push.py200
-rw-r--r--test/modules/http2/test_401_early_hints.py47
-rw-r--r--test/modules/http2/test_500_proxy.py151
-rw-r--r--test/modules/http2/test_501_proxy_serverheader.py36
-rw-r--r--test/modules/http2/test_502_proxy_port.py41
-rw-r--r--test/modules/http2/test_600_h2proxy.py175
-rw-r--r--test/modules/http2/test_700_load_get.py63
-rw-r--r--test/modules/http2/test_710_load_post_static.py65
-rw-r--r--test/modules/http2/test_711_load_post_cgi.py73
-rw-r--r--test/modules/http2/test_712_buffering.py58
47 files changed, 3879 insertions, 0 deletions
diff --git a/test/modules/http2/.gitignore b/test/modules/http2/.gitignore
new file mode 100644
index 0000000..d68cd09
--- /dev/null
+++ b/test/modules/http2/.gitignore
@@ -0,0 +1,3 @@
+gen
+config.ini
+__pycache__
diff --git a/test/modules/http2/Makefile.in b/test/modules/http2/Makefile.in
new file mode 100644
index 0000000..15d404d
--- /dev/null
+++ b/test/modules/http2/Makefile.in
@@ -0,0 +1,20 @@
+
+# no targets: we don't want to build anything by default. if you want the
+# test programs, then "make test"
+TARGETS =
+
+bin_PROGRAMS =
+
+PROGRAM_LDADD = $(EXTRA_LDFLAGS) $(PROGRAM_DEPENDENCIES) $(EXTRA_LIBS)
+PROGRAM_DEPENDENCIES = \
+ $(top_srcdir)/srclib/apr-util/libaprutil.la \
+ $(top_srcdir)/srclib/apr/libapr.la
+
+include $(top_builddir)/build/rules.mk
+
+test: $(bin_PROGRAMS)
+
+# example for building a test proggie
+# dbu_OBJECTS = dbu.lo
+# dbu: $(dbu_OBJECTS)
+# $(LINK) $(dbu_OBJECTS) $(PROGRAM_LDADD)
diff --git a/test/modules/http2/__init__.py b/test/modules/http2/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/modules/http2/__init__.py
diff --git a/test/modules/http2/conftest.py b/test/modules/http2/conftest.py
new file mode 100644
index 0000000..55d0c3a
--- /dev/null
+++ b/test/modules/http2/conftest.py
@@ -0,0 +1,40 @@
+import logging
+import os
+
+import pytest
+import sys
+
+sys.path.append(os.path.join(os.path.dirname(__file__), '../..'))
+
+from .env import H2TestEnv
+
+
+def pytest_report_header(config, startdir):
+ env = H2TestEnv()
+ return f"mod_h2 [apache: {env.get_httpd_version()}, mpm: {env.mpm_module}, {env.prefix}]"
+
+
+@pytest.fixture(scope="package")
+def env(pytestconfig) -> H2TestEnv:
+ 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 = H2TestEnv(pytestconfig=pytestconfig)
+ env.setup_httpd()
+ env.apache_access_log_clear()
+ env.httpd_error_log.clear_log()
+ return env
+
+
+@pytest.fixture(autouse=True, scope="package")
+def _session_scope(env):
+ yield
+ assert env.apache_stop() == 0
+ errors, warnings = env.httpd_error_log.get_missed()
+ assert (len(errors), len(warnings)) == (0, 0),\
+ f"apache logged {len(errors)} errors and {len(warnings)} warnings: \n"\
+ "{0}\n{1}\n".format("\n".join(errors), "\n".join(warnings))
+
diff --git a/test/modules/http2/env.py b/test/modules/http2/env.py
new file mode 100644
index 0000000..537d3bf
--- /dev/null
+++ b/test/modules/http2/env.py
@@ -0,0 +1,168 @@
+import inspect
+import logging
+import os
+import re
+import subprocess
+from typing import Dict, Any
+
+from pyhttpd.certs import CertificateSpec
+from pyhttpd.conf import HttpdConf
+from pyhttpd.env import HttpdTestEnv, HttpdTestSetup
+
+log = logging.getLogger(__name__)
+
+
+class H2TestSetup(HttpdTestSetup):
+
+ def __init__(self, env: 'HttpdTestEnv'):
+ super().__init__(env=env)
+ self.add_source_dir(os.path.dirname(inspect.getfile(H2TestSetup)))
+ self.add_modules(["http2", "proxy_http2", "cgid", "autoindex", "ssl"])
+
+ def make(self):
+ super().make()
+ self._add_h2test()
+ self._setup_data_1k_1m()
+
+ def _add_h2test(self):
+ local_dir = os.path.dirname(inspect.getfile(H2TestSetup))
+ p = subprocess.run([self.env.apxs, '-c', 'mod_h2test.c'],
+ capture_output=True,
+ cwd=os.path.join(local_dir, 'mod_h2test'))
+ rv = p.returncode
+ if rv != 0:
+ log.error(f"compiling md_h2test failed: {p.stderr}")
+ raise Exception(f"compiling md_h2test failed: {p.stderr}")
+
+ modules_conf = os.path.join(self.env.server_dir, 'conf/modules.conf')
+ with open(modules_conf, 'a') as fd:
+ # load our test module which is not installed
+ fd.write(f"LoadModule h2test_module \"{local_dir}/mod_h2test/.libs/mod_h2test.so\"\n")
+
+ def _setup_data_1k_1m(self):
+ s90 = "01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678\n"
+ with open(os.path.join(self.env.gen_dir, "data-1k"), 'w') as f:
+ for i in range(10):
+ f.write(f"{i:09d}-{s90}")
+ with open(os.path.join(self.env.gen_dir, "data-10k"), 'w') as f:
+ for i in range(100):
+ f.write(f"{i:09d}-{s90}")
+ with open(os.path.join(self.env.gen_dir, "data-100k"), 'w') as f:
+ for i in range(1000):
+ f.write(f"{i:09d}-{s90}")
+ with open(os.path.join(self.env.gen_dir, "data-1m"), 'w') as f:
+ for i in range(10000):
+ f.write(f"{i:09d}-{s90}")
+
+
+class H2TestEnv(HttpdTestEnv):
+
+ @classmethod
+ @property
+ def is_unsupported(cls):
+ mpm_module = f"mpm_{os.environ['MPM']}" if 'MPM' in os.environ else 'mpm_event'
+ return mpm_module == 'mpm_prefork'
+
+ def __init__(self, pytestconfig=None):
+ super().__init__(pytestconfig=pytestconfig)
+ self.add_httpd_conf([
+ "H2MinWorkers 1",
+ "H2MaxWorkers 64",
+ "Protocols h2 http/1.1 h2c",
+ ])
+ self.add_httpd_log_modules(["http2", "proxy_http2", "h2test", "proxy", "proxy_http"])
+ self.add_cert_specs([
+ CertificateSpec(domains=[
+ f"push.{self._http_tld}",
+ f"hints.{self._http_tld}",
+ f"ssl.{self._http_tld}",
+ f"pad0.{self._http_tld}",
+ f"pad1.{self._http_tld}",
+ f"pad2.{self._http_tld}",
+ f"pad3.{self._http_tld}",
+ f"pad8.{self._http_tld}",
+ ]),
+ CertificateSpec(domains=[f"noh2.{self.http_tld}"], key_type='rsa2048'),
+ ])
+
+ self.httpd_error_log.set_ignored_lognos([
+ 'AH02032',
+ 'AH01276',
+ 'AH01630',
+ 'AH00135',
+ 'AH02261', # Re-negotiation handshake failed (our test_101)
+ 'AH03490', # scoreboard full, happens on limit tests
+ 'AH02429', # invalid chars in response header names, see test_h2_200
+ 'AH02430', # invalid chars in response header values, see test_h2_200
+ 'AH10373', # SSL errors on uncompleted handshakes, see test_h2_105
+ 'AH01247', # mod_cgid sometimes freaks out on load tests
+ 'AH01110', # error by proxy reading response
+ 'AH10400', # warning that 'enablereuse' has not effect in certain configs test_h2_600
+ ])
+ self.httpd_error_log.add_ignored_patterns([
+ re.compile(r'.*malformed header from script \'hecho.py\': Bad header: x.*'),
+ re.compile(r'.*:tls_post_process_client_hello:.*'),
+ # OSSL 3 dropped the function name from the error description. Use the code instead:
+ # 0A0000C1 = no shared cipher -- Too restrictive SSLCipherSuite or using DSA server certificate?
+ re.compile(r'.*SSL Library Error: error:0A0000C1:.*'),
+ re.compile(r'.*:tls_process_client_certificate:.*'),
+ # OSSL 3 dropped the function name from the error description. Use the code instead:
+ # 0A0000C7 = peer did not return a certificate -- No CAs known to server for verification?
+ re.compile(r'.*SSL Library Error: error:0A0000C7:.*'),
+ re.compile(r'.*have incompatible TLS configurations.'),
+ ])
+
+ def setup_httpd(self, setup: HttpdTestSetup = None):
+ super().setup_httpd(setup=H2TestSetup(env=self))
+
+
+class H2Conf(HttpdConf):
+
+ def __init__(self, env: HttpdTestEnv, extras: Dict[str, Any] = None):
+ super().__init__(env=env, extras=HttpdConf.merge_extras(extras, {
+ f"cgi.{env.http_tld}": [
+ "SSLOptions +StdEnvVars",
+ "AddHandler cgi-script .py",
+ "<Location \"/h2test/echo\">",
+ " SetHandler h2test-echo",
+ "</Location>",
+ "<Location \"/h2test/delay\">",
+ " SetHandler h2test-delay",
+ "</Location>",
+ "<Location \"/h2test/error\">",
+ " SetHandler h2test-error",
+ "</Location>",
+ ]
+ }))
+
+ def start_vhost(self, domains, port=None, doc_root="htdocs", with_ssl=None,
+ ssl_module=None, with_certificates=None):
+ super().start_vhost(domains=domains, port=port, doc_root=doc_root,
+ with_ssl=with_ssl, ssl_module=ssl_module,
+ with_certificates=with_certificates)
+ if f"noh2.{self.env.http_tld}" in domains:
+ protos = ["http/1.1"]
+ elif port == self.env.https_port or with_ssl is True:
+ protos = ["h2", "http/1.1"]
+ else:
+ protos = ["h2c", "http/1.1"]
+ if f"test2.{self.env.http_tld}" in domains:
+ protos = reversed(protos)
+ self.add(f"Protocols {' '.join(protos)}")
+ return self
+
+ def add_vhost_noh2(self):
+ domains = [f"noh2.{self.env.http_tld}", f"noh2-alias.{self.env.http_tld}"]
+ self.start_vhost(domains=domains, port=self.env.https_port, doc_root="htdocs/noh2")
+ self.add(["Protocols http/1.1", "SSLOptions +StdEnvVars"])
+ self.end_vhost()
+ self.start_vhost(domains=domains, port=self.env.http_port, doc_root="htdocs/noh2")
+ self.add(["Protocols http/1.1", "SSLOptions +StdEnvVars"])
+ self.end_vhost()
+ return self
+
+ def add_vhost_test1(self, proxy_self=False, h2proxy_self=False):
+ return super().add_vhost_test1(proxy_self=proxy_self, h2proxy_self=h2proxy_self)
+
+ def add_vhost_test2(self):
+ return super().add_vhost_test2()
diff --git a/test/modules/http2/htdocs/cgi/alive.json b/test/modules/http2/htdocs/cgi/alive.json
new file mode 100644
index 0000000..defe2c2
--- /dev/null
+++ b/test/modules/http2/htdocs/cgi/alive.json
@@ -0,0 +1,4 @@
+{
+ "host" : "cgi",
+ "alive" : true
+}
diff --git a/test/modules/http2/htdocs/cgi/echo.py b/test/modules/http2/htdocs/cgi/echo.py
new file mode 100644
index 0000000..c9083e1
--- /dev/null
+++ b/test/modules/http2/htdocs/cgi/echo.py
@@ -0,0 +1,16 @@
+#!/usr/bin/env python3
+import os, sys
+import multipart
+
+status = '200 Ok'
+
+content = ''
+for line in sys.stdin:
+ content += line
+
+# Just echo what we get
+print("Status: 200")
+print(f"Request-Length: {len(content)}")
+print("Content-Type: application/data\n")
+sys.stdout.write(content)
+
diff --git a/test/modules/http2/htdocs/cgi/echohd.py b/test/modules/http2/htdocs/cgi/echohd.py
new file mode 100644
index 0000000..2a138cd
--- /dev/null
+++ b/test/modules/http2/htdocs/cgi/echohd.py
@@ -0,0 +1,37 @@
+#!/usr/bin/env python3
+import os, sys
+import multipart
+from urllib import parse
+
+
+def get_request_params():
+ oforms = {}
+ if "REQUEST_URI" in os.environ:
+ qforms = parse.parse_qs(parse.urlsplit(os.environ["REQUEST_URI"]).query)
+ for name, values in qforms.items():
+ oforms[name] = values[0]
+ myenv = os.environ.copy()
+ myenv['wsgi.input'] = sys.stdin.buffer
+ mforms, ofiles = multipart.parse_form_data(environ=myenv)
+ for name, item in mforms.items():
+ oforms[name] = item
+ return oforms, ofiles
+
+
+forms, files = get_request_params()
+name = forms['name'] if 'name' in forms else None
+
+if name:
+ print("Status: 200")
+ print("""\
+Content-Type: text/plain\n""")
+ print("""%s: %s""" % (name, os.environ['HTTP_'+name]))
+else:
+ print("Status: 400 Parameter Missing")
+ print("""\
+Content-Type: text/html\n
+<html><body>
+<p>No name was specified</p>
+</body></html>""")
+
+
diff --git a/test/modules/http2/htdocs/cgi/env.py b/test/modules/http2/htdocs/cgi/env.py
new file mode 100644
index 0000000..3af5764
--- /dev/null
+++ b/test/modules/http2/htdocs/cgi/env.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+import os, sys
+import multipart
+from urllib import parse
+
+
+def get_request_params():
+ oforms = {}
+ if "REQUEST_URI" in os.environ:
+ qforms = parse.parse_qs(parse.urlsplit(os.environ["REQUEST_URI"]).query)
+ for name, values in qforms.items():
+ oforms[name] = values[0]
+ myenv = os.environ.copy()
+ myenv['wsgi.input'] = sys.stdin.buffer
+ mforms, ofiles = multipart.parse_form_data(environ=myenv)
+ for name, item in mforms.items():
+ oforms[name] = item
+ return oforms, ofiles
+
+
+forms, files = get_request_params()
+
+status = '200 Ok'
+
+try:
+ ename = forms['name']
+
+ # Test if the file was uploaded
+ if ename is not None:
+ val = os.environ[ename] if ename in os.environ else ""
+ print("Status: 200")
+ print("""\
+Content-Type: text/plain\n""")
+ print(f"{ename}={val}")
+
+ else:
+ print("Status: 400 Parameter Missing")
+ print("""\
+Content-Type: text/html\n
+ <html><body>
+ <p>No name was specified: name</p>
+ </body></html>""")
+
+except KeyError:
+ print("Status: 200 Ok")
+ print("""\
+Content-Type: text/html\n
+ <html><body>
+ Echo <form method="POST" enctype="application/x-www-form-urlencoded">
+ <input type="text" name="name">
+ <button type="submit">submit</button></form>
+ </body></html>""")
+ pass
+
+
+
diff --git a/test/modules/http2/htdocs/cgi/files/empty.txt b/test/modules/http2/htdocs/cgi/files/empty.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/modules/http2/htdocs/cgi/files/empty.txt
diff --git a/test/modules/http2/htdocs/cgi/hecho.py b/test/modules/http2/htdocs/cgi/hecho.py
new file mode 100644
index 0000000..fb9e330
--- /dev/null
+++ b/test/modules/http2/htdocs/cgi/hecho.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+import os, sys
+import multipart
+from urllib import parse
+
+
+def get_request_params():
+ oforms = {}
+ if "REQUEST_URI" in os.environ:
+ qforms = parse.parse_qs(parse.urlsplit(os.environ["REQUEST_URI"]).query)
+ for name, values in qforms.items():
+ oforms[name] = values[0]
+ myenv = os.environ.copy()
+ myenv['wsgi.input'] = sys.stdin.buffer
+ mforms, ofiles = multipart.parse_form_data(environ=myenv)
+ for name, item in mforms.items():
+ oforms[name] = item
+ return oforms, ofiles
+
+
+forms, files = get_request_params()
+
+status = '200 Ok'
+
+try:
+
+ # A nested FieldStorage instance holds the file
+ name = forms['name']
+ value = ''
+
+ try:
+ value = forms['value']
+ except KeyError:
+ value = os.environ.get("HTTP_"+name, "unset")
+
+ # Test if a value was given
+ if name:
+ print("Status: 200")
+ print("%s: %s" % (name, value,))
+ print ("""\
+Content-Type: text/plain\n""")
+
+ else:
+ print("Status: 400 Parameter Missing")
+ print("""\
+Content-Type: text/html\n
+ <html><body>
+ <p>No name and value was specified: %s %s</p>
+ </body></html>""" % (name, value))
+
+except KeyError:
+ print("Status: 200 Ok")
+ print("""\
+Content-Type: text/html\n
+ <html><body>
+ Echo <form method="POST" enctype="application/x-www-form-urlencoded">
+ <input type="text" name="name">
+ <input type="text" name="value">
+ <button type="submit">Echo</button></form>
+ </body></html>""")
+ pass
+
+
diff --git a/test/modules/http2/htdocs/cgi/hello.py b/test/modules/http2/htdocs/cgi/hello.py
new file mode 100644
index 0000000..20974bf
--- /dev/null
+++ b/test/modules/http2/htdocs/cgi/hello.py
@@ -0,0 +1,20 @@
+#!/usr/bin/env python3
+
+import os
+
+print("Content-Type: application/json")
+print()
+print("{")
+print(" \"https\" : \"%s\"," % (os.getenv('HTTPS', '')))
+print(" \"host\" : \"%s\"," % (os.getenv('X_HOST', '') \
+ if 'X_HOST' in os.environ else os.getenv('SERVER_NAME', '')))
+print(" \"server\" : \"%s\"," % (os.getenv('SERVER_NAME', '')))
+print(" \"h2_original_host\" : \"%s\"," % (os.getenv('H2_ORIGINAL_HOST', '')))
+print(" \"port\" : \"%s\"," % (os.getenv('SERVER_PORT', '')))
+print(" \"protocol\" : \"%s\"," % (os.getenv('SERVER_PROTOCOL', '')))
+print(" \"ssl_protocol\" : \"%s\"," % (os.getenv('SSL_PROTOCOL', '')))
+print(" \"h2\" : \"%s\"," % (os.getenv('HTTP2', '')))
+print(" \"h2push\" : \"%s\"," % (os.getenv('H2PUSH', '')))
+print(" \"h2_stream_id\" : \"%s\"" % (os.getenv('H2_STREAM_ID', '')))
+print("}")
+
diff --git a/test/modules/http2/htdocs/cgi/mnot164.py b/test/modules/http2/htdocs/cgi/mnot164.py
new file mode 100644
index 0000000..c29ccc1
--- /dev/null
+++ b/test/modules/http2/htdocs/cgi/mnot164.py
@@ -0,0 +1,31 @@
+#!/usr/bin/env python3
+import os, sys
+import multipart
+from urllib import parse
+
+
+def get_request_params():
+ oforms = {}
+ if "REQUEST_URI" in os.environ:
+ qforms = parse.parse_qs(parse.urlsplit(os.environ["REQUEST_URI"]).query)
+ for name, values in qforms.items():
+ oforms[name] = values[0]
+ myenv = os.environ.copy()
+ myenv['wsgi.input'] = sys.stdin.buffer
+ mforms, ofiles = multipart.parse_form_data(environ=myenv)
+ for name, item in mforms.items():
+ oforms[name] = item
+ return oforms, ofiles
+
+
+forms, files = get_request_params()
+text = forms['text'] if 'text' in forms else "a"
+count = int(forms['count']) if 'count' in forms else 77784
+
+print("Status: 200 OK")
+print("Content-Type: text/html")
+print()
+sys.stdout.flush()
+for _ in range(count):
+ sys.stdout.write(text)
+
diff --git a/test/modules/http2/htdocs/cgi/necho.py b/test/modules/http2/htdocs/cgi/necho.py
new file mode 100644
index 0000000..78e2aad
--- /dev/null
+++ b/test/modules/http2/htdocs/cgi/necho.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+import time
+import os, sys
+import multipart
+from urllib import parse
+
+
+def get_request_params():
+ oforms = {}
+ if "REQUEST_URI" in os.environ:
+ qforms = parse.parse_qs(parse.urlsplit(os.environ["REQUEST_URI"]).query)
+ for name, values in qforms.items():
+ oforms[name] = values[0]
+ myenv = os.environ.copy()
+ myenv['wsgi.input'] = sys.stdin.buffer
+ mforms, ofiles = multipart.parse_form_data(environ=myenv)
+ for name, item in mforms.items():
+ oforms[name] = item
+ return oforms, ofiles
+
+
+forms, files = get_request_params()
+status = '200 Ok'
+
+try:
+ count = forms['count']
+ text = forms['text']
+
+ waitsec = float(forms['wait1']) if 'wait1' in forms else 0.0
+ if waitsec > 0:
+ time.sleep(waitsec)
+
+ if int(count):
+ print("Status: 200")
+ print("""\
+Content-Type: text/plain\n""")
+
+ waitsec = float(forms['wait2']) if 'wait2' in forms else 0.0
+ if waitsec > 0:
+ time.sleep(waitsec)
+
+ i = 0;
+ for i in range(0, int(count)):
+ print("%s" % (text))
+
+ waitsec = float(forms['wait3']) if 'wait3' in forms else 0.0
+ if waitsec > 0:
+ time.sleep(waitsec)
+
+ else:
+ print("Status: 400 Parameter Missing")
+ print("""\
+Content-Type: text/html\n
+ <html><body>
+ <p>No count was specified: %s</p>
+ </body></html>""" % (count))
+
+except KeyError:
+ print("Status: 200 Ok")
+ print("""\
+Content-Type: text/html\n
+ <html><body>
+ Echo <form method="POST" enctype="application/x-www-form-urlencoded">
+ <input type="text" name="count">
+ <input type="text" name="text">
+ <button type="submit">Echo</button></form>
+ </body></html>""")
+ pass
+
+
diff --git a/test/modules/http2/htdocs/cgi/upload.py b/test/modules/http2/htdocs/cgi/upload.py
new file mode 100644
index 0000000..59fbb58
--- /dev/null
+++ b/test/modules/http2/htdocs/cgi/upload.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+import os
+import sys
+import multipart
+from urllib import parse
+
+
+try: # Windows needs stdio set for binary mode.
+ import msvcrt
+
+ msvcrt.setmode(0, os.O_BINARY) # stdin = 0
+ msvcrt.setmode(1, os.O_BINARY) # stdout = 1
+except ImportError:
+ pass
+
+def get_request_params():
+ oforms = {}
+ if "REQUEST_URI" in os.environ:
+ qforms = parse.parse_qs(parse.urlsplit(os.environ["REQUEST_URI"]).query)
+ for name, values in qforms.items():
+ oforms[name] = values[0]
+ myenv = os.environ.copy()
+ myenv['wsgi.input'] = sys.stdin.buffer
+ mforms, ofiles = multipart.parse_form_data(environ=myenv)
+ for name, item in mforms.items():
+ oforms[name] = item
+ return oforms, ofiles
+
+
+forms, files = get_request_params()
+
+status = '200 Ok'
+
+# Test if the file was uploaded
+if 'file' in files:
+ fitem = files['file']
+ # strip leading path from file name to avoid directory traversal attacks
+ fname = fitem.filename
+ fpath = f'{os.environ["DOCUMENT_ROOT"]}/files/{fname}'
+ fitem.save_as(fpath)
+ message = "The file %s was uploaded successfully" % (fname)
+ print("Status: 201 Created")
+ print("Content-Type: text/html")
+ print("Location: %s://%s/files/%s" % (os.environ["REQUEST_SCHEME"], os.environ["HTTP_HOST"], fname))
+ print("")
+ print("<html><body><p>%s</p></body></html>" % (message))
+
+elif 'remove' in forms:
+ remove = forms['remove']
+ try:
+ fname = os.path.basename(remove)
+ os.remove('./files/' + fname)
+ message = 'The file "' + fname + '" was removed successfully'
+ except OSError as e:
+ message = 'Error removing ' + fname + ': ' + e.strerror
+ status = '404 File Not Found'
+ print("Status: %s" % (status))
+ print("""
+Content-Type: text/html
+
+<html><body>
+<p>%s</p>
+</body></html>""" % (message))
+
+else:
+ message = '''\
+ Upload File<form method="POST" enctype="multipart/form-data">
+ <input type="file" name="file">
+ <button type="submit">Upload</button></form>
+ '''
+ print("Status: %s" % (status))
+ print("""\
+Content-Type: text/html
+
+<html><body>
+<p>%s</p>
+</body></html>""" % (message))
+
diff --git a/test/modules/http2/htdocs/noh2/alive.json b/test/modules/http2/htdocs/noh2/alive.json
new file mode 100644
index 0000000..7b54893
--- /dev/null
+++ b/test/modules/http2/htdocs/noh2/alive.json
@@ -0,0 +1,5 @@
+{
+ "host" : "noh2",
+ "alive" : true
+}
+
diff --git a/test/modules/http2/htdocs/noh2/index.html b/test/modules/http2/htdocs/noh2/index.html
new file mode 100644
index 0000000..696068e
--- /dev/null
+++ b/test/modules/http2/htdocs/noh2/index.html
@@ -0,0 +1,9 @@
+<html>
+ <head>
+ <title>mod_h2 test site noh2</title>
+ </head>
+ <body>
+ <h1>mod_h2 test site noh2</h1>
+ </body>
+</html>
+
diff --git a/test/modules/http2/mod_h2test/mod_h2test.c b/test/modules/http2/mod_h2test/mod_h2test.c
new file mode 100644
index 0000000..b5ee8ad
--- /dev/null
+++ b/test/modules/http2/mod_h2test/mod_h2test.c
@@ -0,0 +1,527 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include <apr_optional.h>
+#include <apr_optional_hooks.h>
+#include <apr_strings.h>
+#include <apr_cstr.h>
+#include <apr_time.h>
+#include <apr_want.h>
+
+#include <httpd.h>
+#include <http_protocol.h>
+#include <http_request.h>
+#include <http_log.h>
+
+#include "mod_h2test.h"
+
+static void h2test_hooks(apr_pool_t *pool);
+
+AP_DECLARE_MODULE(h2test) = {
+ STANDARD20_MODULE_STUFF,
+ NULL, /* func to create per dir config */
+ NULL, /* func to merge per dir config */
+ NULL, /* func to create per server config */
+ NULL, /* func to merge per server config */
+ NULL, /* command handlers */
+ h2test_hooks,
+#if defined(AP_MODULE_FLAG_NONE)
+ AP_MODULE_FLAG_ALWAYS_MERGE
+#endif
+};
+
+#define SECS_PER_HOUR (60*60)
+#define SECS_PER_DAY (24*SECS_PER_HOUR)
+
+static apr_status_t duration_parse(apr_interval_time_t *ptimeout, const char *value,
+ const char *def_unit)
+{
+ char *endp;
+ apr_int64_t n;
+
+ n = apr_strtoi64(value, &endp, 10);
+ if (errno) {
+ return errno;
+ }
+ if (!endp || !*endp) {
+ if (!def_unit) def_unit = "s";
+ }
+ else if (endp == value) {
+ return APR_EINVAL;
+ }
+ else {
+ def_unit = endp;
+ }
+
+ switch (*def_unit) {
+ case 'D':
+ case 'd':
+ *ptimeout = apr_time_from_sec(n * SECS_PER_DAY);
+ break;
+ case 's':
+ case 'S':
+ *ptimeout = (apr_interval_time_t) apr_time_from_sec(n);
+ break;
+ case 'h':
+ case 'H':
+ /* Time is in hours */
+ *ptimeout = (apr_interval_time_t) apr_time_from_sec(n * SECS_PER_HOUR);
+ break;
+ case 'm':
+ case 'M':
+ switch (*(++def_unit)) {
+ /* Time is in milliseconds */
+ case 's':
+ case 'S':
+ *ptimeout = (apr_interval_time_t) n * 1000;
+ break;
+ /* Time is in minutes */
+ case 'i':
+ case 'I':
+ *ptimeout = (apr_interval_time_t) apr_time_from_sec(n * 60);
+ break;
+ default:
+ return APR_EGENERAL;
+ }
+ break;
+ default:
+ return APR_EGENERAL;
+ }
+ return APR_SUCCESS;
+}
+
+static int h2test_post_config(apr_pool_t *p, apr_pool_t *plog,
+ apr_pool_t *ptemp, server_rec *s)
+{
+ void *data = NULL;
+ const char *mod_h2_init_key = "mod_h2test_init_counter";
+
+ (void)plog;(void)ptemp;
+
+ apr_pool_userdata_get(&data, mod_h2_init_key, s->process->pool);
+ if ( data == NULL ) {
+ /* dry run */
+ apr_pool_userdata_set((const void *)1, mod_h2_init_key,
+ apr_pool_cleanup_null, s->process->pool);
+ return APR_SUCCESS;
+ }
+
+
+ return APR_SUCCESS;
+}
+
+static void h2test_child_init(apr_pool_t *pool, server_rec *s)
+{
+ (void)pool;
+ (void)s;
+}
+
+static int h2test_echo_handler(request_rec *r)
+{
+ conn_rec *c = r->connection;
+ apr_bucket_brigade *bb;
+ apr_bucket *b;
+ apr_status_t rv;
+ char buffer[8192];
+ const char *ct;
+ long l;
+
+ if (strcmp(r->handler, "h2test-echo")) {
+ return DECLINED;
+ }
+ if (r->method_number != M_GET && r->method_number != M_POST) {
+ return DECLINED;
+ }
+
+ ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "echo_handler: processing request");
+ r->status = 200;
+ r->clength = -1;
+ r->chunked = 1;
+ apr_table_unset(r->headers_out, "Content-Length");
+ /* Discourage content-encodings */
+ apr_table_unset(r->headers_out, "Content-Encoding");
+ apr_table_setn(r->subprocess_env, "no-brotli", "1");
+ apr_table_setn(r->subprocess_env, "no-gzip", "1");
+
+ ct = apr_table_get(r->headers_in, "content-type");
+ ap_set_content_type(r, ct? ct : "application/octet-stream");
+
+ bb = apr_brigade_create(r->pool, c->bucket_alloc);
+ /* copy any request body into the response */
+ if ((rv = ap_setup_client_block(r, REQUEST_CHUNKED_DECHUNK))) goto cleanup;
+ if (ap_should_client_block(r)) {
+ while (0 < (l = ap_get_client_block(r, &buffer[0], sizeof(buffer)))) {
+ ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r,
+ "echo_handler: copying %ld bytes from request body", l);
+ rv = apr_brigade_write(bb, NULL, NULL, buffer, l);
+ if (APR_SUCCESS != rv) goto cleanup;
+ rv = ap_pass_brigade(r->output_filters, bb);
+ if (APR_SUCCESS != rv) goto cleanup;
+ ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r,
+ "echo_handler: passed %ld bytes from request body", l);
+ }
+ }
+ /* we are done */
+ b = apr_bucket_eos_create(c->bucket_alloc);
+ APR_BRIGADE_INSERT_TAIL(bb, b);
+ ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "echo_handler: request read");
+
+ if (r->trailers_in && !apr_is_empty_table(r->trailers_in)) {
+ ap_log_rerror(APLOG_MARK, APLOG_TRACE2, 0, r,
+ "echo_handler: seeing incoming trailers");
+ apr_table_setn(r->trailers_out, "h2test-trailers-in",
+ apr_itoa(r->pool, 1));
+ }
+
+ rv = ap_pass_brigade(r->output_filters, bb);
+
+cleanup:
+ if (rv == APR_SUCCESS
+ || r->status != HTTP_OK
+ || c->aborted) {
+ ap_log_rerror(APLOG_MARK, APLOG_TRACE1, rv, r, "echo_handler: request handled");
+ return OK;
+ }
+ else {
+ /* no way to know what type of error occurred */
+ ap_log_rerror(APLOG_MARK, APLOG_TRACE1, rv, r, "h2test_echo_handler failed");
+ return AP_FILTER_ERROR;
+ }
+ return DECLINED;
+}
+
+static int h2test_delay_handler(request_rec *r)
+{
+ conn_rec *c = r->connection;
+ apr_bucket_brigade *bb;
+ apr_bucket *b;
+ apr_status_t rv;
+ char buffer[8192];
+ int i, chunks = 3;
+ long l;
+ apr_time_t delay = 0;
+
+ if (strcmp(r->handler, "h2test-delay")) {
+ return DECLINED;
+ }
+ if (r->method_number != M_GET && r->method_number != M_POST) {
+ return DECLINED;
+ }
+
+ if (r->args) {
+ rv = duration_parse(&delay, r->args, "s");
+ if (APR_SUCCESS != rv) {
+ ap_die(HTTP_BAD_REQUEST, r);
+ return OK;
+ }
+ }
+
+ ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "delay_handler: processing request, %ds delay",
+ (int)apr_time_sec(delay));
+ r->status = 200;
+ r->clength = -1;
+ r->chunked = 1;
+ apr_table_unset(r->headers_out, "Content-Length");
+ /* Discourage content-encodings */
+ apr_table_unset(r->headers_out, "Content-Encoding");
+ apr_table_setn(r->subprocess_env, "no-brotli", "1");
+ apr_table_setn(r->subprocess_env, "no-gzip", "1");
+
+ ap_set_content_type(r, "application/octet-stream");
+
+ bb = apr_brigade_create(r->pool, c->bucket_alloc);
+ /* copy any request body into the response */
+ if ((rv = ap_setup_client_block(r, REQUEST_CHUNKED_DECHUNK))) goto cleanup;
+ if (ap_should_client_block(r)) {
+ do {
+ l = ap_get_client_block(r, &buffer[0], sizeof(buffer));
+ if (l > 0) {
+ ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r,
+ "delay_handler: reading %ld bytes from request body", l);
+ }
+ } while (l > 0);
+ if (l < 0) {
+ return AP_FILTER_ERROR;
+ }
+ }
+
+ memset(buffer, 0, sizeof(buffer));
+ l = sizeof(buffer);
+ for (i = 0; i < chunks; ++i) {
+ rv = apr_brigade_write(bb, NULL, NULL, buffer, l);
+ if (APR_SUCCESS != rv) goto cleanup;
+ rv = ap_pass_brigade(r->output_filters, bb);
+ if (APR_SUCCESS != rv) goto cleanup;
+ ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r,
+ "delay_handler: passed %ld bytes as response body", l);
+ if (delay) {
+ apr_sleep(delay);
+ }
+ }
+ /* we are done */
+ b = apr_bucket_eos_create(c->bucket_alloc);
+ APR_BRIGADE_INSERT_TAIL(bb, b);
+ rv = ap_pass_brigade(r->output_filters, bb);
+ apr_brigade_cleanup(bb);
+ ap_log_rerror(APLOG_MARK, APLOG_TRACE1, rv, r, "delay_handler: response passed");
+
+cleanup:
+ ap_log_rerror(APLOG_MARK, APLOG_TRACE1, rv, r,
+ "delay_handler: request cleanup, r->status=%d, aborte=%d",
+ r->status, c->aborted);
+ if (rv == APR_SUCCESS
+ || r->status != HTTP_OK
+ || c->aborted) {
+ return OK;
+ }
+ return AP_FILTER_ERROR;
+}
+
+static int h2test_trailer_handler(request_rec *r)
+{
+ conn_rec *c = r->connection;
+ apr_bucket_brigade *bb;
+ apr_bucket *b;
+ apr_status_t rv;
+ char buffer[8192];
+ long l;
+ int body_len = 0;
+
+ if (strcmp(r->handler, "h2test-trailer")) {
+ return DECLINED;
+ }
+ if (r->method_number != M_GET && r->method_number != M_POST) {
+ return DECLINED;
+ }
+
+ if (r->args) {
+ body_len = (int)apr_atoi64(r->args);
+ if (body_len < 0) body_len = 0;
+ }
+
+ ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "trailer_handler: processing request, %d body length",
+ body_len);
+ r->status = 200;
+ r->clength = body_len;
+ ap_set_content_length(r, body_len);
+
+ ap_set_content_type(r, "application/octet-stream");
+ apr_table_mergen(r->headers_out, "Trailer", "trailer-content-length");
+ apr_table_set(r->trailers_out, "trailer-content-length",
+ apr_psprintf(r->pool, "%d", body_len));
+
+ bb = apr_brigade_create(r->pool, c->bucket_alloc);
+ memset(buffer, 0, sizeof(buffer));
+ while (body_len > 0) {
+ l = (sizeof(buffer) > body_len)? body_len : sizeof(buffer);
+ body_len -= l;
+ rv = apr_brigade_write(bb, NULL, NULL, buffer, l);
+ if (APR_SUCCESS != rv) goto cleanup;
+ rv = ap_pass_brigade(r->output_filters, bb);
+ if (APR_SUCCESS != rv) goto cleanup;
+ ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r,
+ "trailer_handler: passed %ld bytes as response body", l);
+ }
+ /* we are done */
+ b = apr_bucket_eos_create(c->bucket_alloc);
+ APR_BRIGADE_INSERT_TAIL(bb, b);
+ rv = ap_pass_brigade(r->output_filters, bb);
+ apr_brigade_cleanup(bb);
+ ap_log_rerror(APLOG_MARK, APLOG_TRACE1, rv, r, "trailer_handler: response passed");
+
+cleanup:
+ ap_log_rerror(APLOG_MARK, APLOG_TRACE1, rv, r,
+ "trailer_handler: request cleanup, r->status=%d, aborte=%d",
+ r->status, c->aborted);
+ if (rv == APR_SUCCESS
+ || r->status != HTTP_OK
+ || c->aborted) {
+ return OK;
+ }
+ return AP_FILTER_ERROR;
+}
+
+static int status_from_str(const char *s, apr_status_t *pstatus)
+{
+ if (!strcmp("timeout", s)) {
+ *pstatus = APR_TIMEUP;
+ return 1;
+ }
+ else if (!strcmp("reset", s)) {
+ *pstatus = APR_ECONNRESET;
+ return 1;
+ }
+ return 0;
+}
+
+static int h2test_error_handler(request_rec *r)
+{
+ conn_rec *c = r->connection;
+ apr_bucket_brigade *bb;
+ apr_bucket *b;
+ apr_status_t rv;
+ char buffer[8192];
+ int i, chunks = 3, error_bucket = 1;
+ long l;
+ apr_time_t delay = 0, body_delay = 0;
+ apr_array_header_t *args = NULL;
+ int http_status = 200;
+ apr_status_t error = APR_SUCCESS, body_error = APR_SUCCESS;
+
+ if (strcmp(r->handler, "h2test-error")) {
+ return DECLINED;
+ }
+ if (r->method_number != M_GET && r->method_number != M_POST) {
+ return DECLINED;
+ }
+
+ if (r->args) {
+ args = apr_cstr_split(r->args, "&", 1, r->pool);
+ for (i = 0; i < args->nelts; ++i) {
+ char *s, *val, *arg = APR_ARRAY_IDX(args, i, char*);
+ s = strchr(arg, '=');
+ if (s) {
+ *s = '\0';
+ val = s + 1;
+ if (!strcmp("status", arg)) {
+ http_status = (int)apr_atoi64(val);
+ if (val > 0) {
+ continue;
+ }
+ }
+ else if (!strcmp("error", arg)) {
+ if (status_from_str(val, &error)) {
+ continue;
+ }
+ }
+ else if (!strcmp("error_bucket", arg)) {
+ error_bucket = (int)apr_atoi64(val);
+ if (val >= 0) {
+ continue;
+ }
+ }
+ else if (!strcmp("body_error", arg)) {
+ if (status_from_str(val, &body_error)) {
+ continue;
+ }
+ }
+ else if (!strcmp("delay", arg)) {
+ rv = duration_parse(&delay, r->args, "s");
+ if (APR_SUCCESS == rv) {
+ continue;
+ }
+ }
+ else if (!strcmp("body_delay", arg)) {
+ rv = duration_parse(&body_delay, r->args, "s");
+ if (APR_SUCCESS == rv) {
+ continue;
+ }
+ }
+ }
+ ap_die(HTTP_BAD_REQUEST, r);
+ return OK;
+ }
+ }
+
+ ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "error_handler: processing request, %s",
+ r->args? r->args : "(no args)");
+ r->status = http_status;
+ r->clength = -1;
+ r->chunked = 1;
+ apr_table_unset(r->headers_out, "Content-Length");
+ /* Discourage content-encodings */
+ apr_table_unset(r->headers_out, "Content-Encoding");
+ apr_table_setn(r->subprocess_env, "no-brotli", "1");
+ apr_table_setn(r->subprocess_env, "no-gzip", "1");
+
+ ap_set_content_type(r, "application/octet-stream");
+ bb = apr_brigade_create(r->pool, c->bucket_alloc);
+
+ if (delay) {
+ apr_sleep(delay);
+ }
+ if (error != APR_SUCCESS) {
+ return ap_map_http_request_error(error, HTTP_BAD_REQUEST);
+ }
+ /* flush response */
+ b = apr_bucket_flush_create(c->bucket_alloc);
+ APR_BRIGADE_INSERT_TAIL(bb, b);
+ rv = ap_pass_brigade(r->output_filters, bb);
+ if (APR_SUCCESS != rv) goto cleanup;
+
+ memset(buffer, 'X', sizeof(buffer));
+ l = sizeof(buffer);
+ for (i = 0; i < chunks; ++i) {
+ if (body_delay) {
+ apr_sleep(body_delay);
+ }
+ rv = apr_brigade_write(bb, NULL, NULL, buffer, l);
+ if (APR_SUCCESS != rv) goto cleanup;
+ rv = ap_pass_brigade(r->output_filters, bb);
+ if (APR_SUCCESS != rv) goto cleanup;
+ ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r,
+ "error_handler: passed %ld bytes as response body", l);
+ if (body_error != APR_SUCCESS) {
+ rv = body_error;
+ goto cleanup;
+ }
+ }
+ /* we are done */
+ b = apr_bucket_eos_create(c->bucket_alloc);
+ APR_BRIGADE_INSERT_TAIL(bb, b);
+ rv = ap_pass_brigade(r->output_filters, bb);
+ apr_brigade_cleanup(bb);
+ ap_log_rerror(APLOG_MARK, APLOG_TRACE1, rv, r, "error_handler: response passed");
+
+cleanup:
+ ap_log_rerror(APLOG_MARK, APLOG_TRACE1, rv, r,
+ "error_handler: request cleanup, r->status=%d, aborted=%d",
+ r->status, c->aborted);
+ if (rv == APR_SUCCESS) {
+ return OK;
+ }
+ if (error_bucket) {
+ http_status = ap_map_http_request_error(rv, HTTP_BAD_REQUEST);
+ b = ap_bucket_error_create(http_status, NULL, r->pool, c->bucket_alloc);
+ APR_BRIGADE_INSERT_TAIL(bb, b);
+ ap_pass_brigade(r->output_filters, bb);
+ }
+ return AP_FILTER_ERROR;
+}
+
+/* Install this module into the apache2 infrastructure.
+ */
+static void h2test_hooks(apr_pool_t *pool)
+{
+ static const char *const mod_h2[] = { "mod_h2.c", NULL};
+
+ ap_log_perror(APLOG_MARK, APLOG_TRACE1, 0, pool, "installing hooks and handlers");
+
+ /* Run once after configuration is set, but before mpm children initialize.
+ */
+ ap_hook_post_config(h2test_post_config, mod_h2, NULL, APR_HOOK_MIDDLE);
+
+ /* Run once after a child process has been created.
+ */
+ ap_hook_child_init(h2test_child_init, NULL, NULL, APR_HOOK_MIDDLE);
+
+ /* test h2 handlers */
+ ap_hook_handler(h2test_echo_handler, NULL, NULL, APR_HOOK_MIDDLE);
+ ap_hook_handler(h2test_delay_handler, NULL, NULL, APR_HOOK_MIDDLE);
+ ap_hook_handler(h2test_trailer_handler, NULL, NULL, APR_HOOK_MIDDLE);
+ ap_hook_handler(h2test_error_handler, NULL, NULL, APR_HOOK_MIDDLE);
+}
+
diff --git a/test/modules/http2/mod_h2test/mod_h2test.h b/test/modules/http2/mod_h2test/mod_h2test.h
new file mode 100644
index 0000000..a886d29
--- /dev/null
+++ b/test/modules/http2/mod_h2test/mod_h2test.h
@@ -0,0 +1,21 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef __MOD_H2TEST_H__
+#define __MOD_H2TEST_H__
+
+
+#endif
diff --git a/test/modules/http2/test_001_httpd_alive.py b/test/modules/http2/test_001_httpd_alive.py
new file mode 100644
index 0000000..b5708d2
--- /dev/null
+++ b/test/modules/http2/test_001_httpd_alive.py
@@ -0,0 +1,22 @@
+import pytest
+
+from .env import H2Conf, H2TestEnv
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestBasicAlive:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ H2Conf(env).add_vhost_test1().install()
+ assert env.apache_restart() == 0
+
+ # we expect to see the document from the generic server
+ def test_h2_001_01(self, env):
+ url = env.mkurl("https", "test1", "/alive.json")
+ r = env.curl_get(url, 5)
+ assert r.exit_code == 0, r.stderr + r.stdout
+ assert r.response["json"]
+ assert r.response["json"]["alive"] is True
+ assert r.response["json"]["host"] == "test1"
+
diff --git a/test/modules/http2/test_002_curl_basics.py b/test/modules/http2/test_002_curl_basics.py
new file mode 100644
index 0000000..91be772
--- /dev/null
+++ b/test/modules/http2/test_002_curl_basics.py
@@ -0,0 +1,71 @@
+import pytest
+
+from .env import H2Conf, H2TestEnv
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestCurlBasics:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ conf = H2Conf(env)
+ conf.add_vhost_test1()
+ conf.add_vhost_test2()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ # check that we see the correct documents when using the test1 server name over http:
+ def test_h2_002_01(self, env):
+ url = env.mkurl("http", "test1", "/alive.json")
+ r = env.curl_get(url, 5)
+ assert r.response["status"] == 200
+ assert "HTTP/1.1" == r.response["protocol"]
+ assert r.response["json"]["alive"] is True
+ assert r.response["json"]["host"] == "test1"
+
+ # check that we see the correct documents when using the test1 server name over https:
+ def test_h2_002_02(self, env):
+ url = env.mkurl("https", "test1", "/alive.json")
+ r = env.curl_get(url, 5)
+ assert r.response["status"] == 200
+ assert r.response["json"]["alive"] is True
+ assert "test1" == r.response["json"]["host"]
+ assert r.response["header"]["content-type"] == "application/json"
+
+ # enforce HTTP/1.1
+ def test_h2_002_03(self, env):
+ url = env.mkurl("https", "test1", "/alive.json")
+ r = env.curl_get(url, 5, options=["--http1.1"])
+ assert r.response["status"] == 200
+ assert r.response["protocol"] == "HTTP/1.1"
+
+ # enforce HTTP/2
+ def test_h2_002_04(self, env):
+ url = env.mkurl("https", "test1", "/alive.json")
+ r = env.curl_get(url, 5, options=["--http2"])
+ assert r.response["status"] == 200
+ assert r.response["protocol"] == "HTTP/2"
+
+ # default is HTTP/2 on this host
+ def test_h2_002_04b(self, env):
+ url = env.mkurl("https", "test1", "/alive.json")
+ r = env.curl_get(url, 5)
+ assert r.response["status"] == 200
+ assert r.response["protocol"] == "HTTP/2"
+ assert r.response["json"]["host"] == "test1"
+
+ # although, without ALPN, we cannot select it
+ def test_h2_002_05(self, env):
+ url = env.mkurl("https", "test1", "/alive.json")
+ r = env.curl_get(url, 5, options=["--no-alpn"])
+ assert r.response["status"] == 200
+ assert r.response["protocol"] == "HTTP/1.1"
+ assert r.response["json"]["host"] == "test1"
+
+ # default is HTTP/1.1 on the other
+ def test_h2_002_06(self, env):
+ url = env.mkurl("https", "test2", "/alive.json")
+ r = env.curl_get(url, 5)
+ assert r.response["status"] == 200
+ assert r.response["protocol"] == "HTTP/1.1"
+ assert r.response["json"]["host"] == "test2"
diff --git a/test/modules/http2/test_003_get.py b/test/modules/http2/test_003_get.py
new file mode 100644
index 0000000..410097a
--- /dev/null
+++ b/test/modules/http2/test_003_get.py
@@ -0,0 +1,265 @@
+import re
+import pytest
+
+from .env import H2Conf, H2TestEnv
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestGet:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ H2Conf(env).add_vhost_cgi(
+ proxy_self=True, h2proxy_self=True
+ ).add_vhost_test1(
+ proxy_self=True, h2proxy_self=True
+ ).install()
+ assert env.apache_restart() == 0
+
+ # check SSL environment variables from CGI script
+ def test_h2_003_01(self, env):
+ url = env.mkurl("https", "cgi", "/hello.py")
+ r = env.curl_get(url, 5, options=["--tlsv1.2"])
+ assert r.response["status"] == 200
+ assert r.response["json"]["protocol"] == "HTTP/2.0"
+ assert r.response["json"]["https"] == "on"
+ tls_version = r.response["json"]["ssl_protocol"]
+ assert tls_version in ["TLSv1.2", "TLSv1.3"]
+ assert r.response["json"]["h2"] == "on"
+ assert r.response["json"]["h2push"] == "off"
+
+ r = env.curl_get(url, 5, options=["--http1.1", "--tlsv1.2"])
+ assert r.response["status"] == 200
+ assert "HTTP/1.1" == r.response["json"]["protocol"]
+ assert "on" == r.response["json"]["https"]
+ tls_version = r.response["json"]["ssl_protocol"]
+ assert tls_version in ["TLSv1.2", "TLSv1.3"]
+ assert "" == r.response["json"]["h2"]
+ assert "" == r.response["json"]["h2push"]
+
+ # retrieve a html file from the server and compare it to its source
+ def test_h2_003_02(self, env):
+ with open(env.htdocs_src("test1/index.html"), mode='rb') as file:
+ src = file.read()
+
+ url = env.mkurl("https", "test1", "/index.html")
+ r = env.curl_get(url, 5)
+ assert r.response["status"] == 200
+ assert "HTTP/2" == r.response["protocol"]
+ assert src == r.response["body"]
+
+ url = env.mkurl("https", "test1", "/index.html")
+ r = env.curl_get(url, 5, options=["--http1.1"])
+ assert r.response["status"] == 200
+ assert "HTTP/1.1" == r.response["protocol"]
+ assert src == r.response["body"]
+
+ # retrieve chunked content from a cgi script
+ def check_necho(self, env, n, text):
+ url = env.mkurl("https", "cgi", "/necho.py")
+ r = env.curl_get(url, 5, options=["-F", f"count={n}", "-F", f"text={text}"])
+ assert r.response["status"] == 200
+ exp = ""
+ for i in range(n):
+ exp += text + "\n"
+ assert exp == r.response["body"].decode('utf-8')
+
+ def test_h2_003_10(self, env):
+ self.check_necho(env, 10, "0123456789")
+
+ def test_h2_003_11(self, env):
+ self.check_necho(env, 100, "0123456789")
+
+ def test_h2_003_12(self, env):
+ self.check_necho(env, 1000, "0123456789")
+
+ def test_h2_003_13(self, env):
+ self.check_necho(env, 10000, "0123456789")
+
+ def test_h2_003_14(self, env):
+ self.check_necho(env, 100000, "0123456789")
+
+ # github issue #126
+ def test_h2_003_20(self, env):
+ url = env.mkurl("https", "test1", "/006/")
+ r = env.curl_get(url, 5)
+ assert r.response["status"] == 200
+ body = r.response["body"].decode('utf-8')
+ # our doctype varies between branches and in time, lets not compare
+ body = re.sub(r'^<!DOCTYPE[^>]+>', '', body)
+ assert '''
+<html>
+ <head>
+ <title>Index of /006</title>
+ </head>
+ <body>
+<h1>Index of /006</h1>
+<ul><li><a href="/"> Parent Directory</a></li>
+<li><a href="006.css"> 006.css</a></li>
+<li><a href="006.js"> 006.js</a></li>
+<li><a href="header.html"> header.html</a></li>
+</ul>
+</body></html>
+''' == body
+
+ # github issue #133
+ def clean_header(self, s):
+ s = re.sub(r'\r\n', '\n', s, flags=re.MULTILINE)
+ s = re.sub(r'^date:.*\n', '', s, flags=re.MULTILINE)
+ s = re.sub(r'^server:.*\n', '', s, flags=re.MULTILINE)
+ s = re.sub(r'^last-modified:.*\n', '', s, flags=re.MULTILINE)
+ s = re.sub(r'^etag:.*\n', '', s, flags=re.MULTILINE)
+ s = re.sub(r'^vary:.*\n', '', s, flags=re.MULTILINE)
+ return re.sub(r'^accept-ranges:.*\n', '', s, flags=re.MULTILINE)
+
+ def test_h2_003_21(self, env):
+ url = env.mkurl("https", "test1", "/index.html")
+ r = env.curl_get(url, 5, options=["-I"])
+ assert r.response["status"] == 200
+ assert "HTTP/2" == r.response["protocol"]
+ s = self.clean_header(r.response["body"].decode('utf-8'))
+ assert '''HTTP/2 200
+content-length: 2007
+content-type: text/html
+
+''' == s
+
+ r = env.curl_get(url, 5, options=["-I", url])
+ assert r.response["status"] == 200
+ assert "HTTP/2" == r.response["protocol"]
+ s = self.clean_header(r.response["body"].decode('utf-8'))
+ assert '''HTTP/2 200
+content-length: 2007
+content-type: text/html
+
+HTTP/2 200
+content-length: 2007
+content-type: text/html
+
+''' == s
+
+ # test conditionals: if-modified-since
+ @pytest.mark.parametrize("path", [
+ "/004.html", "/proxy/004.html", "/h2proxy/004.html"
+ ])
+ def test_h2_003_30(self, env, path):
+ url = env.mkurl("https", "test1", path)
+ r = env.curl_get(url, 5)
+ assert r.response["status"] == 200
+ assert "HTTP/2" == r.response["protocol"]
+ h = r.response["header"]
+ assert "last-modified" in h
+ lastmod = h["last-modified"]
+ r = env.curl_get(url, 5, options=['-H', ("if-modified-since: %s" % lastmod)])
+ assert 304 == r.response["status"]
+
+ # test conditionals: if-etag
+ @pytest.mark.parametrize("path", [
+ "/004.html", "/proxy/004.html", "/h2proxy/004.html"
+ ])
+ def test_h2_003_31(self, env, path):
+ url = env.mkurl("https", "test1", path)
+ r = env.curl_get(url, 5)
+ assert r.response["status"] == 200
+ assert "HTTP/2" == r.response["protocol"]
+ h = r.response["header"]
+ assert "etag" in h
+ etag = h["etag"]
+ r = env.curl_get(url, 5, options=['-H', ("if-none-match: %s" % etag)])
+ assert 304 == r.response["status"]
+
+ # test various response body lengths to work correctly
+ def test_h2_003_40(self, env):
+ n = 1001
+ while n <= 1025024:
+ url = env.mkurl("https", "cgi", f"/mnot164.py?count={n}&text=X")
+ r = env.curl_get(url, 5)
+ assert r.response["status"] == 200
+ assert "HTTP/2" == r.response["protocol"]
+ assert n == len(r.response["body"])
+ n *= 2
+
+ # test various response body lengths to work correctly
+ @pytest.mark.parametrize("n", [
+ 0, 1, 1291, 1292, 80000, 80123, 81087, 98452
+ ])
+ def test_h2_003_41(self, env, n):
+ url = env.mkurl("https", "cgi", f"/mnot164.py?count={n}&text=X")
+ r = env.curl_get(url, 5)
+ assert r.response["status"] == 200
+ assert "HTTP/2" == r.response["protocol"]
+ assert n == len(r.response["body"])
+
+ # test ranges
+ @pytest.mark.parametrize("path", [
+ "/004.html", "/proxy/004.html", "/h2proxy/004.html"
+ ])
+ def test_h2_003_50(self, env, path):
+ # check that the resource supports ranges and we see its raw content-length
+ url = env.mkurl("https", "test1", path)
+ r = env.curl_get(url, 5)
+ assert r.response["status"] == 200
+ assert "HTTP/2" == r.response["protocol"]
+ h = r.response["header"]
+ assert "accept-ranges" in h
+ assert "bytes" == h["accept-ranges"]
+ assert "content-length" in h
+ clen = h["content-length"]
+ # get the first 1024 bytes of the resource, 206 status, but content-length as original
+ r = env.curl_get(url, 5, options=["-H", "range: bytes=0-1023"])
+ assert 206 == r.response["status"]
+ assert "HTTP/2" == r.response["protocol"]
+ assert 1024 == len(r.response["body"])
+ assert "content-length" in h
+ assert clen == h["content-length"]
+
+ # use an invalid scheme
+ def test_h2_003_51(self, env):
+ url = env.mkurl("https", "cgi", "/")
+ opt = ["-H:scheme: invalid"]
+ r = env.nghttp().get(url, options=opt)
+ assert r.exit_code == 0, r
+ assert r.response['status'] == 400
+
+ # use an differing scheme, but one that is acceptable
+ def test_h2_003_52(self, env):
+ url = env.mkurl("https", "cgi", "/")
+ opt = ["-H:scheme: http"]
+ r = env.nghttp().get(url, options=opt)
+ assert r.exit_code == 0, r
+ assert r.response['status'] == 200
+
+ # Test that we get a proper `Date` and `Server` headers on responses
+ def test_h2_003_60(self, env):
+ url = env.mkurl("https", "test1", "/index.html")
+ r = env.curl_get(url)
+ assert r.exit_code == 0, r
+ assert r.response['status'] == 200
+ assert 'date' in r.response['header']
+ assert 'server' in r.response['header']
+
+ # lets do some error tests
+ def test_h2_003_70(self, env):
+ url = env.mkurl("https", "cgi", "/h2test/error?status=500")
+ r = env.curl_get(url)
+ assert r.exit_code == 0, r
+ assert r.response['status'] == 500
+ url = env.mkurl("https", "cgi", "/h2test/error?error=timeout")
+ r = env.curl_get(url)
+ assert r.exit_code == 0, r
+ assert r.response['status'] == 408
+
+ # produce an error during response body
+ def test_h2_003_71(self, env, repeat):
+ url = env.mkurl("https", "cgi", "/h2test/error?body_error=timeout")
+ r = env.curl_get(url)
+ assert r.exit_code != 0, f"{r}"
+ url = env.mkurl("https", "cgi", "/h2test/error?body_error=reset")
+ r = env.curl_get(url)
+ assert r.exit_code != 0, f"{r}"
+
+ # produce an error, fail to generate an error bucket
+ def test_h2_003_72(self, env, repeat):
+ url = env.mkurl("https", "cgi", "/h2test/error?body_error=timeout&error_bucket=0")
+ r = env.curl_get(url)
+ assert r.exit_code != 0, f"{r}"
diff --git a/test/modules/http2/test_004_post.py b/test/modules/http2/test_004_post.py
new file mode 100644
index 0000000..44f31d2
--- /dev/null
+++ b/test/modules/http2/test_004_post.py
@@ -0,0 +1,219 @@
+import difflib
+import email.parser
+import inspect
+import json
+import os
+import re
+import sys
+import time
+
+import pytest
+
+from .env import H2Conf, H2TestEnv
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestPost:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ TestPost._local_dir = os.path.dirname(inspect.getfile(TestPost))
+ H2Conf(env).add_vhost_cgi().install()
+ assert env.apache_restart() == 0
+
+ def local_src(self, fname):
+ return os.path.join(TestPost._local_dir, fname)
+
+ # upload and GET again using curl, compare to original content
+ def curl_upload_and_verify(self, env, fname, options=None):
+ url = env.mkurl("https", "cgi", "/upload.py")
+ fpath = os.path.join(env.gen_dir, fname)
+ r = env.curl_upload(url, fpath, options=options)
+ assert r.exit_code == 0, f"{r}"
+ assert 200 <= r.response["status"] < 300
+
+ r2 = env.curl_get(r.response["header"]["location"])
+ assert r2.exit_code == 0
+ assert r2.response["status"] == 200
+ with open(self.local_src(fpath), mode='rb') as file:
+ src = file.read()
+ assert src == r2.response["body"]
+
+ def test_h2_004_01(self, env):
+ self.curl_upload_and_verify(env, "data-1k", ["-vvv", "--http1.1"])
+ self.curl_upload_and_verify(env, "data-1k", ["--http2"])
+
+ def test_h2_004_02(self, env):
+ self.curl_upload_and_verify(env, "data-10k", ["--http1.1"])
+ self.curl_upload_and_verify(env, "data-10k", ["--http2"])
+
+ def test_h2_004_03(self, env):
+ self.curl_upload_and_verify(env, "data-100k", ["--http1.1"])
+ self.curl_upload_and_verify(env, "data-100k", ["--http2"])
+
+ def test_h2_004_04(self, env):
+ self.curl_upload_and_verify(env, "data-1m", ["--http1.1"])
+ self.curl_upload_and_verify(env, "data-1m", ["--http2"])
+
+ def test_h2_004_05(self, env):
+ self.curl_upload_and_verify(env, "data-1k", ["-v", "--http1.1", "-H", "Expect: 100-continue"])
+ self.curl_upload_and_verify(env, "data-1k", ["-v", "--http2", "-H", "Expect: 100-continue"])
+
+ @pytest.mark.skipif(True, reason="python3 regresses in chunked inputs to cgi")
+ def test_h2_004_06(self, env):
+ self.curl_upload_and_verify(env, "data-1k", ["--http1.1", "-H", "Content-Length: "])
+ self.curl_upload_and_verify(env, "data-1k", ["--http2", "-H", "Content-Length: "])
+
+ @pytest.mark.parametrize("name, value", [
+ ("HTTP2", "on"),
+ ("H2PUSH", "off"),
+ ("H2_PUSHED", ""),
+ ("H2_PUSHED_ON", ""),
+ ("H2_STREAM_ID", "1"),
+ ("H2_STREAM_TAG", r'\d+-\d+-1'),
+ ])
+ def test_h2_004_07(self, env, name, value):
+ url = env.mkurl("https", "cgi", "/env.py")
+ r = env.curl_post_value(url, "name", name)
+ assert r.exit_code == 0
+ assert r.response["status"] == 200
+ m = re.match("{0}=(.*)".format(name), r.response["body"].decode('utf-8'))
+ assert m
+ assert re.match(value, m.group(1))
+
+ # POST some data using nghttp and see it echo'ed properly back
+ def nghttp_post_and_verify(self, env, fname, options=None):
+ url = env.mkurl("https", "cgi", "/echo.py")
+ fpath = os.path.join(env.gen_dir, fname)
+
+ r = env.nghttp().upload(url, fpath, options=options)
+ assert r.exit_code == 0
+ assert r.response["status"] >= 200 and r.response["status"] < 300
+
+ with open(self.local_src(fpath), mode='rb') as file:
+ src = file.read()
+ assert 'request-length' in r.response["header"]
+ assert int(r.response["header"]['request-length']) == len(src)
+ if len(r.response["body"]) != len(src):
+ sys.stderr.writelines(difflib.unified_diff(
+ src.decode().splitlines(True),
+ r.response["body"].decode().splitlines(True),
+ fromfile='source',
+ tofile='response'
+ ))
+ assert len(r.response["body"]) == len(src)
+ assert r.response["body"] == src, f"expected '{src}', got '{r.response['body']}'"
+
+ @pytest.mark.parametrize("name", [
+ "data-1k", "data-10k", "data-100k", "data-1m"
+ ])
+ def test_h2_004_21(self, env, name):
+ self.nghttp_post_and_verify(env, name, [])
+
+ @pytest.mark.parametrize("name", [
+ "data-1k", "data-10k", "data-100k", "data-1m",
+ ])
+ def test_h2_004_22(self, env, name, repeat):
+ self.nghttp_post_and_verify(env, name, ["--no-content-length"])
+
+ # upload and GET again using nghttp, compare to original content
+ def nghttp_upload_and_verify(self, env, fname, options=None):
+ url = env.mkurl("https", "cgi", "/upload.py")
+ fpath = os.path.join(env.gen_dir, fname)
+
+ r = env.nghttp().upload_file(url, fpath, options=options)
+ assert r.exit_code == 0
+ assert r.response["status"] >= 200 and r.response["status"] < 300
+ assert r.response["header"]["location"]
+
+ r2 = env.nghttp().get(r.response["header"]["location"])
+ assert r2.exit_code == 0
+ assert r2.response["status"] == 200
+ with open(self.local_src(fpath), mode='rb') as file:
+ src = file.read()
+ assert src == r2.response["body"]
+
+ @pytest.mark.parametrize("name", [
+ "data-1k", "data-10k", "data-100k", "data-1m"
+ ])
+ def test_h2_004_23(self, env, name, repeat):
+ self.nghttp_upload_and_verify(env, name, [])
+
+ @pytest.mark.parametrize("name", [
+ "data-1k", "data-10k", "data-100k", "data-1m"
+ ])
+ def test_h2_004_24(self, env, name, repeat):
+ self.nghttp_upload_and_verify(env, name, ["--expect-continue"])
+
+ @pytest.mark.parametrize("name", [
+ "data-1k", "data-10k", "data-100k", "data-1m"
+ ])
+ def test_h2_004_25(self, env, name, repeat):
+ self.nghttp_upload_and_verify(env, name, ["--no-content-length"])
+
+ def test_h2_004_30(self, env):
+ # issue: #203
+ resource = "data-1k"
+ full_length = 1000
+ chunk = 200
+ self.curl_upload_and_verify(env, resource, ["-v", "--http2"])
+ logfile = os.path.join(env.server_logs_dir, "test_004_30")
+ if os.path.isfile(logfile):
+ os.remove(logfile)
+ H2Conf(env).add("""
+LogFormat "{ \\"request\\": \\"%r\\", \\"status\\": %>s, \\"bytes_resp_B\\": %B, \\"bytes_tx_O\\": %O, \\"bytes_rx_I\\": %I, \\"bytes_rx_tx_S\\": %S }" issue_203
+CustomLog logs/test_004_30 issue_203
+ """).add_vhost_cgi().install()
+ assert env.apache_restart() == 0
+ url = env.mkurl("https", "cgi", "/files/{0}".format(resource))
+ r = env.curl_get(url, 5, options=["--http2"])
+ assert r.response["status"] == 200
+ r = env.curl_get(url, 5, options=["--http1.1", "-H", "Range: bytes=0-{0}".format(chunk-1)])
+ assert 206 == r.response["status"]
+ assert chunk == len(r.response["body"].decode('utf-8'))
+ r = env.curl_get(url, 5, options=["--http2", "-H", "Range: bytes=0-{0}".format(chunk-1)])
+ assert 206 == r.response["status"]
+ assert chunk == len(r.response["body"].decode('utf-8'))
+ # Wait for log completeness
+ time.sleep(1)
+ # now check what response lengths have actually been reported
+ lines = open(logfile).readlines()
+ log_h2_full = json.loads(lines[-3])
+ log_h1 = json.loads(lines[-2])
+ log_h2 = json.loads(lines[-1])
+ assert log_h2_full['bytes_rx_I'] > 0
+ assert log_h2_full['bytes_resp_B'] == full_length
+ assert log_h2_full['bytes_tx_O'] > full_length
+ assert log_h1['bytes_rx_I'] > 0 # input bytes received
+ assert log_h1['bytes_resp_B'] == chunk # response bytes sent (payload)
+ assert log_h1['bytes_tx_O'] > chunk # output bytes sent
+ assert log_h2['bytes_rx_I'] > 0
+ assert log_h2['bytes_resp_B'] == chunk
+ assert log_h2['bytes_tx_O'] > chunk
+
+ def test_h2_004_40(self, env):
+ # echo content using h2test_module "echo" handler
+ def post_and_verify(fname, options=None):
+ url = env.mkurl("https", "cgi", "/h2test/echo")
+ fpath = os.path.join(env.gen_dir, fname)
+ r = env.curl_upload(url, fpath, options=options)
+ assert r.exit_code == 0
+ assert r.response["status"] >= 200 and r.response["status"] < 300
+
+ ct = r.response["header"]["content-type"]
+ mail_hd = "Content-Type: " + ct + "\r\nMIME-Version: 1.0\r\n\r\n"
+ mime_msg = mail_hd.encode() + r.response["body"]
+ # this MIME API is from hell
+ body = email.parser.BytesParser().parsebytes(mime_msg)
+ assert body
+ assert body.is_multipart()
+ filepart = None
+ for part in body.walk():
+ if fname == part.get_filename():
+ filepart = part
+ assert filepart
+ with open(self.local_src(fpath), mode='rb') as file:
+ src = file.read()
+ assert src == filepart.get_payload(decode=True)
+
+ post_and_verify("data-1k", [])
diff --git a/test/modules/http2/test_005_files.py b/test/modules/http2/test_005_files.py
new file mode 100644
index 0000000..e761836
--- /dev/null
+++ b/test/modules/http2/test_005_files.py
@@ -0,0 +1,48 @@
+import os
+import pytest
+
+from .env import H2Conf, H2TestEnv
+
+
+def mk_text_file(fpath: str, lines: int):
+ t110 = ""
+ for _ in range(11):
+ t110 += "0123456789"
+ with open(fpath, "w") as fd:
+ for i in range(lines):
+ fd.write("{0:015d}: ".format(i)) # total 128 bytes per line
+ fd.write(t110)
+ fd.write("\n")
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestFiles:
+
+ URI_PATHS = []
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ docs_a = os.path.join(env.server_docs_dir, "cgi/files")
+ uris = []
+ file_count = 32
+ file_sizes = [1, 10, 100, 10000]
+ for i in range(file_count):
+ fsize = file_sizes[i % len(file_sizes)]
+ if fsize is None:
+ raise Exception("file sizes?: {0} {1}".format(i, fsize))
+ fname = "{0}-{1}k.txt".format(i, fsize)
+ mk_text_file(os.path.join(docs_a, fname), 8 * fsize)
+ self.URI_PATHS.append(f"/files/{fname}")
+
+ H2Conf(env).add_vhost_cgi(
+ proxy_self=True, h2proxy_self=True
+ ).add_vhost_test1(
+ proxy_self=True, h2proxy_self=True
+ ).install()
+ assert env.apache_restart() == 0
+
+ def test_h2_005_01(self, env):
+ url = env.mkurl("https", "cgi", self.URI_PATHS[2])
+ r = env.curl_get(url)
+ assert r.response, r.stderr + r.stdout
+ assert r.response["status"] == 200
diff --git a/test/modules/http2/test_006_assets.py b/test/modules/http2/test_006_assets.py
new file mode 100644
index 0000000..778314e
--- /dev/null
+++ b/test/modules/http2/test_006_assets.py
@@ -0,0 +1,75 @@
+import pytest
+
+from .env import H2Conf, H2TestEnv
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestAssets:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ H2Conf(env).add_vhost_test1().install()
+ assert env.apache_restart() == 0
+
+ # single page without any assets
+ def test_h2_006_01(self, env):
+ url = env.mkurl("https", "test1", "/001.html")
+ r = env.nghttp().assets(url, options=["-Haccept-encoding: none"])
+ assert 0 == r.exit_code
+ assert 1 == len(r.assets)
+ assert r.assets == [
+ {"status": 200, "size": "251", "path": "/001.html"}
+ ]
+
+ # single image without any assets
+ def test_h2_006_02(self, env):
+ url = env.mkurl("https", "test1", "/002.jpg")
+ r = env.nghttp().assets(url, options=["-Haccept-encoding: none"])
+ assert 0 == r.exit_code
+ assert 1 == len(r.assets)
+ assert r.assets == [
+ {"status": 200, "size": "88K", "path": "/002.jpg"}
+ ]
+
+ # gophertiles, yea!
+ def test_h2_006_03(self, env):
+ # create the tiles files we originally had checked in
+ exp_assets = [
+ {"status": 200, "size": "10K", "path": "/004.html"},
+ {"status": 200, "size": "742", "path": "/004/gophertiles.jpg"},
+ ]
+ for i in range(2, 181):
+ with open(f"{env.server_docs_dir}/test1/004/gophertiles_{i:03d}.jpg", "w") as fd:
+ fd.write("0123456789\n")
+ exp_assets.append(
+ {"status": 200, "size": "11", "path": f"/004/gophertiles_{i:03d}.jpg"},
+ )
+
+ url = env.mkurl("https", "test1", "/004.html")
+ r = env.nghttp().assets(url, options=["-Haccept-encoding: none"])
+ assert 0 == r.exit_code
+ assert 181 == len(r.assets)
+ assert r.assets == exp_assets
+
+ # page with js and css
+ def test_h2_006_04(self, env):
+ url = env.mkurl("https", "test1", "/006.html")
+ r = env.nghttp().assets(url, options=["-Haccept-encoding: none"])
+ assert 0 == r.exit_code
+ assert 3 == len(r.assets)
+ assert r.assets == [
+ {"status": 200, "size": "543", "path": "/006.html"},
+ {"status": 200, "size": "216", "path": "/006/006.css"},
+ {"status": 200, "size": "839", "path": "/006/006.js"}
+ ]
+
+ # page with image, try different window size
+ def test_h2_006_05(self, env):
+ url = env.mkurl("https", "test1", "/003.html")
+ r = env.nghttp().assets(url, options=["--window-bits=24", "-Haccept-encoding: none"])
+ assert 0 == r.exit_code
+ assert 2 == len(r.assets)
+ assert r.assets == [
+ {"status": 200, "size": "316", "path": "/003.html"},
+ {"status": 200, "size": "88K", "path": "/003/003_img.jpg"}
+ ]
diff --git a/test/modules/http2/test_100_conn_reuse.py b/test/modules/http2/test_100_conn_reuse.py
new file mode 100644
index 0000000..3ebac24
--- /dev/null
+++ b/test/modules/http2/test_100_conn_reuse.py
@@ -0,0 +1,57 @@
+import pytest
+
+from .env import H2Conf, H2TestEnv
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestConnReuse:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ H2Conf(env).add_vhost_noh2().add_vhost_test1().add_vhost_cgi().install()
+ assert env.apache_restart() == 0
+
+ # make sure the protocol selection on the different hosts work as expected
+ def test_h2_100_01(self, env):
+ # this host defaults to h2, but we can request h1
+ url = env.mkurl("https", "cgi", "/hello.py")
+ assert "2" == env.curl_protocol_version( url )
+ assert "1.1" == env.curl_protocol_version( url, options=[ "--http1.1" ] )
+
+ # this host does not enable h2, it always falls back to h1
+ url = env.mkurl("https", "noh2", "/hello.py")
+ assert "1.1" == env.curl_protocol_version( url )
+ assert "1.1" == env.curl_protocol_version( url, options=[ "--http2" ] )
+
+ # access a ServerAlias, after using ServerName in SNI
+ def test_h2_100_02(self, env):
+ url = env.mkurl("https", "cgi", "/hello.py")
+ hostname = ("cgi-alias.%s" % env.http_tld)
+ r = env.curl_get(url, 5, options=["-H", f"Host: {hostname}"])
+ assert r.response["status"] == 200
+ assert "HTTP/2" == r.response["protocol"]
+ assert hostname == r.response["json"]["host"]
+
+ # access another vhost, after using ServerName in SNI, that uses same SSL setup
+ def test_h2_100_03(self, env):
+ url = env.mkurl("https", "cgi", "/")
+ hostname = ("test1.%s" % env.http_tld)
+ r = env.curl_get(url, 5, options=[ "-H", "Host:%s" % hostname ])
+ assert r.response["status"] == 200
+ assert "HTTP/2" == r.response["protocol"]
+ assert "text/html" == r.response["header"]["content-type"]
+
+ # access another vhost, after using ServerName in SNI,
+ # that has different SSL certificate. This triggers a 421 (misdirected request) response.
+ def test_h2_100_04(self, env):
+ url = env.mkurl("https", "cgi", "/hello.py")
+ hostname = ("noh2.%s" % env.http_tld)
+ r = env.curl_get(url, 5, options=[ "-H", "Host:%s" % hostname ])
+ assert 421 == r.response["status"]
+
+ # access an unknown vhost, after using ServerName in SNI
+ def test_h2_100_05(self, env):
+ url = env.mkurl("https", "cgi", "/hello.py")
+ hostname = ("unknown.%s" % env.http_tld)
+ r = env.curl_get(url, 5, options=[ "-H", "Host:%s" % hostname ])
+ assert 421 == r.response["status"]
diff --git a/test/modules/http2/test_101_ssl_reneg.py b/test/modules/http2/test_101_ssl_reneg.py
new file mode 100644
index 0000000..66f2638
--- /dev/null
+++ b/test/modules/http2/test_101_ssl_reneg.py
@@ -0,0 +1,130 @@
+import re
+import pytest
+
+from .env import H2Conf, H2TestEnv
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+@pytest.mark.skipif(H2TestEnv.get_ssl_module() != "mod_ssl", reason="only for mod_ssl")
+class TestSslRenegotiation:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ domain = f"ssl.{env.http_tld}"
+ conf = H2Conf(env, extras={
+ 'base': [
+ "SSLCipherSuite ECDHE-RSA-AES256-GCM-SHA384",
+ f"<Directory \"{env.server_dir}/htdocs/ssl-client-verify\">",
+ " Require all granted",
+ " SSLVerifyClient require",
+ " SSLVerifyDepth 0",
+ "</Directory>"
+ ],
+ domain: [
+ "Protocols h2 http/1.1",
+ "<Location /renegotiate/cipher>",
+ " SSLCipherSuite ECDHE-RSA-CHACHA20-POLY1305",
+ "</Location>",
+ "<Location /renegotiate/err-doc-cipher>",
+ " SSLCipherSuite ECDHE-RSA-CHACHA20-POLY1305",
+ " ErrorDocument 403 /forbidden.html",
+ "</Location>",
+ "<Location /renegotiate/verify>",
+ " SSLVerifyClient require",
+ "</Location>",
+ f"<Directory \"{env.server_dir}/htdocs/sslrequire\">",
+ " SSLRequireSSL",
+ "</Directory>",
+ f"<Directory \"{env.server_dir}/htdocs/requiressl\">",
+ " Require ssl",
+ "</Directory>",
+ ]})
+ conf.add_vhost(domains=[domain], port=env.https_port,
+ doc_root=f"{env.server_dir}/htdocs")
+ conf.install()
+ # the dir needs to exists for the configuration to have effect
+ env.mkpath("%s/htdocs/ssl-client-verify" % env.server_dir)
+ env.mkpath("%s/htdocs/renegotiate/cipher" % env.server_dir)
+ env.mkpath("%s/htdocs/sslrequire" % env.server_dir)
+ env.mkpath("%s/htdocs/requiressl" % env.server_dir)
+ assert env.apache_restart() == 0
+
+ # access a resource with SSL renegotiation, using HTTP/1.1
+ def test_h2_101_01(self, env):
+ url = env.mkurl("https", "ssl", "/renegotiate/cipher/")
+ r = env.curl_get(url, options=["-v", "--http1.1", "--tlsv1.2", "--tls-max", "1.2"])
+ assert 0 == r.exit_code, f"{r}"
+ assert r.response
+ assert 403 == r.response["status"]
+
+ # try to renegotiate the cipher, should fail with correct code
+ def test_h2_101_02(self, env):
+ url = env.mkurl("https", "ssl", "/renegotiate/cipher/")
+ r = env.curl_get(url, options=[
+ "-vvv", "--tlsv1.2", "--tls-max", "1.2", "--ciphers", "ECDHE-RSA-AES256-GCM-SHA384"
+ ])
+ assert 0 != r.exit_code
+ assert not r.response
+ assert re.search(r'HTTP_1_1_REQUIRED \(err 13\)', r.stderr)
+
+ # try to renegotiate a client certificate from Location
+ # needs to fail with correct code
+ def test_h2_101_03(self, env):
+ url = env.mkurl("https", "ssl", "/renegotiate/verify/")
+ r = env.curl_get(url, options=["-vvv", "--tlsv1.2", "--tls-max", "1.2"])
+ assert 0 != r.exit_code
+ assert not r.response
+ assert re.search(r'HTTP_1_1_REQUIRED \(err 13\)', r.stderr)
+
+ # try to renegotiate a client certificate from Directory
+ # needs to fail with correct code
+ def test_h2_101_04(self, env):
+ url = env.mkurl("https", "ssl", "/ssl-client-verify/index.html")
+ r = env.curl_get(url, options=["-vvv", "--tlsv1.2", "--tls-max", "1.2"])
+ assert 0 != r.exit_code, f"{r}"
+ assert not r.response
+ assert re.search(r'HTTP_1_1_REQUIRED \(err 13\)', r.stderr)
+
+ # make 10 requests on the same connection, none should produce a status code
+ # reported by erki@example.ee
+ def test_h2_101_05(self, env):
+ r = env.run([env.h2load, "-n", "10", "-c", "1", "-m", "1", "-vvvv",
+ f"{env.https_base_url}/ssl-client-verify/index.html"])
+ assert 0 == r.exit_code
+ r = env.h2load_status(r)
+ assert 10 == r.results["h2load"]["requests"]["total"]
+ assert 10 == r.results["h2load"]["requests"]["started"]
+ assert 10 == r.results["h2load"]["requests"]["done"]
+ assert 0 == r.results["h2load"]["requests"]["succeeded"]
+ assert 0 == r.results["h2load"]["status"]["2xx"]
+ assert 0 == r.results["h2load"]["status"]["3xx"]
+ assert 0 == r.results["h2load"]["status"]["4xx"]
+ assert 0 == r.results["h2load"]["status"]["5xx"]
+
+ # Check that "SSLRequireSSL" works on h2 connections
+ # See <https://bz.apache.org/bugzilla/show_bug.cgi?id=62654>
+ def test_h2_101_10a(self, env):
+ url = env.mkurl("https", "ssl", "/sslrequire/index.html")
+ r = env.curl_get(url)
+ assert 0 == r.exit_code
+ assert r.response
+ assert 404 == r.response["status"]
+
+ # Check that "require ssl" works on h2 connections
+ # See <https://bz.apache.org/bugzilla/show_bug.cgi?id=62654>
+ def test_h2_101_10b(self, env):
+ url = env.mkurl("https", "ssl", "/requiressl/index.html")
+ r = env.curl_get(url)
+ assert 0 == r.exit_code
+ assert r.response
+ assert 404 == r.response["status"]
+
+ # Check that status works with ErrorDoc, see pull #174, fixes #172
+ def test_h2_101_11(self, env):
+ url = env.mkurl("https", "ssl", "/renegotiate/err-doc-cipher")
+ r = env.curl_get(url, options=[
+ "-vvv", "--tlsv1.2", "--tls-max", "1.2", "--ciphers", "ECDHE-RSA-AES256-GCM-SHA384"
+ ])
+ assert 0 != r.exit_code
+ assert not r.response
+ assert re.search(r'HTTP_1_1_REQUIRED \(err 13\)', r.stderr)
diff --git a/test/modules/http2/test_102_require.py b/test/modules/http2/test_102_require.py
new file mode 100644
index 0000000..b7e4eae
--- /dev/null
+++ b/test/modules/http2/test_102_require.py
@@ -0,0 +1,41 @@
+import pytest
+
+from .env import H2Conf, H2TestEnv
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestRequire:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ domain = f"ssl.{env.http_tld}"
+ conf = H2Conf(env)
+ conf.start_vhost(domains=[domain], port=env.https_port)
+ conf.add("""
+ Protocols h2 http/1.1
+ SSLOptions +StdEnvVars
+ <Location /h2only.html>
+ Require expr \"%{HTTP2} == 'on'\"
+ </Location>
+ <Location /noh2.html>
+ Require expr \"%{HTTP2} == 'off'\"
+ </Location>""")
+ conf.end_vhost()
+ conf.install()
+ # the dir needs to exists for the configuration to have effect
+ env.mkpath(f"{env.server_dir}/htdocs/ssl-client-verify")
+ assert env.apache_restart() == 0
+
+ def test_h2_102_01(self, env):
+ url = env.mkurl("https", "ssl", "/h2only.html")
+ r = env.curl_get(url)
+ assert 0 == r.exit_code
+ assert r.response
+ assert 404 == r.response["status"]
+
+ def test_h2_102_02(self, env):
+ url = env.mkurl("https", "ssl", "/noh2.html")
+ r = env.curl_get(url)
+ assert 0 == r.exit_code
+ assert r.response
+ assert 403 == r.response["status"]
diff --git a/test/modules/http2/test_103_upgrade.py b/test/modules/http2/test_103_upgrade.py
new file mode 100644
index 0000000..2fa7d1d
--- /dev/null
+++ b/test/modules/http2/test_103_upgrade.py
@@ -0,0 +1,118 @@
+import pytest
+
+from .env import H2Conf, H2TestEnv
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestUpgrade:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ H2Conf(env).add_vhost_test1().add_vhost_test2().add_vhost_noh2(
+ ).start_vhost(domains=[f"test3.{env.http_tld}"], port=env.https_port, doc_root="htdocs/test1"
+ ).add(
+ """
+ Protocols h2 http/1.1
+ Header unset Upgrade"""
+ ).end_vhost(
+ ).start_vhost(domains=[f"test1b.{env.http_tld}"], port=env.http_port, doc_root="htdocs/test1"
+ ).add(
+ """
+ Protocols h2c http/1.1
+ H2Upgrade off
+ <Location /006.html>
+ H2Upgrade on
+ </Location>"""
+ ).end_vhost(
+ ).install()
+ assert env.apache_restart() == 0
+
+ # accessing http://test1, will not try h2 and advertise h2 in the response
+ def test_h2_103_01(self, env):
+ url = env.mkurl("http", "test1", "/index.html")
+ r = env.curl_get(url)
+ assert 0 == r.exit_code
+ assert r.response
+ assert "upgrade" in r.response["header"]
+ assert "h2c" == r.response["header"]["upgrade"]
+
+ # accessing http://noh2, will not advertise, because noh2 host does not have it enabled
+ def test_h2_103_02(self, env):
+ url = env.mkurl("http", "noh2", "/index.html")
+ r = env.curl_get(url)
+ assert 0 == r.exit_code
+ assert r.response
+ assert "upgrade" not in r.response["header"]
+
+ # accessing http://test2, will not advertise, because h2 has less preference than http/1.1
+ def test_h2_103_03(self, env):
+ url = env.mkurl("http", "test2", "/index.html")
+ r = env.curl_get(url)
+ assert 0 == r.exit_code
+ assert r.response
+ assert "upgrade" not in r.response["header"]
+
+ # accessing https://noh2, will not advertise, because noh2 host does not have it enabled
+ def test_h2_103_04(self, env):
+ url = env.mkurl("https", "noh2", "/index.html")
+ r = env.curl_get(url)
+ assert 0 == r.exit_code
+ assert r.response
+ assert "upgrade" not in r.response["header"]
+
+ # accessing https://test2, will not advertise, because h2 has less preference than http/1.1
+ def test_h2_103_05(self, env):
+ url = env.mkurl("https", "test2", "/index.html")
+ r = env.curl_get(url)
+ assert 0 == r.exit_code
+ assert r.response
+ assert "upgrade" not in r.response["header"]
+
+ # accessing https://test1, will advertise h2 in the response
+ def test_h2_103_06(self, env):
+ url = env.mkurl("https", "test1", "/index.html")
+ r = env.curl_get(url, options=["--http1.1"])
+ assert 0 == r.exit_code
+ assert r.response
+ assert "upgrade" in r.response["header"]
+ assert "h2" == r.response["header"]["upgrade"]
+
+ # accessing https://test3, will not send Upgrade since it is suppressed
+ def test_h2_103_07(self, env):
+ url = env.mkurl("https", "test3", "/index.html")
+ r = env.curl_get(url, options=["--http1.1"])
+ assert 0 == r.exit_code
+ assert r.response
+ assert "upgrade" not in r.response["header"]
+
+ # upgrade to h2c for a request, where h2c is preferred
+ def test_h2_103_20(self, env):
+ url = env.mkurl("http", "test1", "/index.html")
+ r = env.nghttp().get(url, options=["-u"])
+ assert r.response["status"] == 200
+
+ # upgrade to h2c for a request where http/1.1 is preferred, but the clients upgrade
+ # wish is honored nevertheless
+ def test_h2_103_21(self, env):
+ url = env.mkurl("http", "test2", "/index.html")
+ r = env.nghttp().get(url, options=["-u"])
+ assert 404 == r.response["status"]
+
+ # ugrade to h2c on a host where h2c is not enabled will fail
+ def test_h2_103_22(self, env):
+ url = env.mkurl("http", "noh2", "/index.html")
+ r = env.nghttp().get(url, options=["-u"])
+ assert not r.response
+
+ # ugrade to h2c on a host where h2c is preferred, but Upgrade is disabled
+ def test_h2_103_23(self, env):
+ url = env.mkurl("http", "test1b", "/index.html")
+ r = env.nghttp().get(url, options=["-u"])
+ assert not r.response
+
+ # ugrade to h2c on a host where h2c is preferred, but Upgrade is disabled on the server,
+ # but allowed for a specific location
+ def test_h2_103_24(self, env):
+ url = env.mkurl("http", "test1b", "/006.html")
+ r = env.nghttp().get(url, options=["-u"])
+ assert r.response["status"] == 200
diff --git a/test/modules/http2/test_104_padding.py b/test/modules/http2/test_104_padding.py
new file mode 100644
index 0000000..7b874ed
--- /dev/null
+++ b/test/modules/http2/test_104_padding.py
@@ -0,0 +1,98 @@
+import pytest
+
+from .env import H2Conf, H2TestEnv
+
+
+def frame_padding(payload, padbits):
+ mask = (1 << padbits) - 1
+ return ((payload + 9 + mask) & ~mask) - (payload + 9)
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestPadding:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ conf = H2Conf(env)
+ conf.start_vhost(domains=[f"ssl.{env.http_tld}"], port=env.https_port, doc_root="htdocs/cgi")
+ conf.add("AddHandler cgi-script .py")
+ conf.end_vhost()
+ conf.start_vhost(domains=[f"pad0.{env.http_tld}"], port=env.https_port, doc_root="htdocs/cgi")
+ conf.add("H2Padding 0")
+ conf.add("AddHandler cgi-script .py")
+ conf.end_vhost()
+ conf.start_vhost(domains=[f"pad1.{env.http_tld}"], port=env.https_port, doc_root="htdocs/cgi")
+ conf.add("H2Padding 1")
+ conf.add("AddHandler cgi-script .py")
+ conf.end_vhost()
+ conf.start_vhost(domains=[f"pad2.{env.http_tld}"], port=env.https_port, doc_root="htdocs/cgi")
+ conf.add("H2Padding 2")
+ conf.add("AddHandler cgi-script .py")
+ conf.end_vhost()
+ conf.start_vhost(domains=[f"pad3.{env.http_tld}"], port=env.https_port, doc_root="htdocs/cgi")
+ conf.add("H2Padding 3")
+ conf.add("AddHandler cgi-script .py")
+ conf.end_vhost()
+ conf.start_vhost(domains=[f"pad8.{env.http_tld}"], port=env.https_port, doc_root="htdocs/cgi")
+ conf.add("H2Padding 8")
+ conf.add("AddHandler cgi-script .py")
+ conf.end_vhost()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ # default paddings settings: 0 bits
+ def test_h2_104_01(self, env):
+ url = env.mkurl("https", "ssl", "/echo.py")
+ # we get 2 frames back: one with data and an empty one with EOF
+ # check the number of padding bytes is as expected
+ for data in ["x", "xx", "xxx", "xxxx", "xxxxx", "xxxxxx", "xxxxxxx", "xxxxxxxx"]:
+ r = env.nghttp().post_data(url, data, 5)
+ assert r.response["status"] == 200
+ assert r.results["paddings"] == [
+ frame_padding(len(data)+1, 0),
+ frame_padding(0, 0)
+ ]
+
+ # 0 bits of padding
+ def test_h2_104_02(self, env):
+ url = env.mkurl("https", "pad0", "/echo.py")
+ for data in ["x", "xx", "xxx", "xxxx", "xxxxx", "xxxxxx", "xxxxxxx", "xxxxxxxx"]:
+ r = env.nghttp().post_data(url, data, 5)
+ assert r.response["status"] == 200
+ assert r.results["paddings"] == [0, 0]
+
+ # 1 bit of padding
+ def test_h2_104_03(self, env):
+ url = env.mkurl("https", "pad1", "/echo.py")
+ for data in ["x", "xx", "xxx", "xxxx", "xxxxx", "xxxxxx", "xxxxxxx", "xxxxxxxx"]:
+ r = env.nghttp().post_data(url, data, 5)
+ assert r.response["status"] == 200
+ for i in r.results["paddings"]:
+ assert i in range(0, 2)
+
+ # 2 bits of padding
+ def test_h2_104_04(self, env):
+ url = env.mkurl("https", "pad2", "/echo.py")
+ for data in ["x", "xx", "xxx", "xxxx", "xxxxx", "xxxxxx", "xxxxxxx", "xxxxxxxx"]:
+ r = env.nghttp().post_data(url, data, 5)
+ assert r.response["status"] == 200
+ for i in r.results["paddings"]:
+ assert i in range(0, 4)
+
+ # 3 bits of padding
+ def test_h2_104_05(self, env):
+ url = env.mkurl("https", "pad3", "/echo.py")
+ for data in ["x", "xx", "xxx", "xxxx", "xxxxx", "xxxxxx", "xxxxxxx", "xxxxxxxx"]:
+ r = env.nghttp().post_data(url, data, 5)
+ assert r.response["status"] == 200
+ for i in r.results["paddings"]:
+ assert i in range(0, 8)
+
+ # 8 bits of padding
+ def test_h2_104_06(self, env):
+ url = env.mkurl("https", "pad8", "/echo.py")
+ for data in ["x", "xx", "xxx", "xxxx", "xxxxx", "xxxxxx", "xxxxxxx", "xxxxxxxx"]:
+ r = env.nghttp().post_data(url, data, 5)
+ assert r.response["status"] == 200
+ for i in r.results["paddings"]:
+ assert i in range(0, 256)
diff --git a/test/modules/http2/test_105_timeout.py b/test/modules/http2/test_105_timeout.py
new file mode 100644
index 0000000..13aa8ed
--- /dev/null
+++ b/test/modules/http2/test_105_timeout.py
@@ -0,0 +1,149 @@
+import socket
+import time
+
+import pytest
+
+from .env import H2Conf
+from pyhttpd.curl import CurlPiper
+
+
+class TestTimeout:
+
+ # Check that base servers 'Timeout' setting is observed on SSL handshake
+ def test_h2_105_01(self, env):
+ conf = H2Conf(env)
+ conf.add("""
+ AcceptFilter http none
+ Timeout 1.5
+ """)
+ conf.add_vhost_cgi()
+ conf.install()
+ assert env.apache_restart() == 0
+ host = 'localhost'
+ # read with a longer timeout than the server
+ sock = socket.create_connection((host, int(env.https_port)))
+ try:
+ # on some OS, the server does not see our connection until there is
+ # something incoming
+ sock.send(b'0')
+ sock.settimeout(4)
+ buff = sock.recv(1024)
+ assert buff == b''
+ except Exception as ex:
+ print(f"server did not close in time: {ex}")
+ assert False
+ sock.close()
+ # read with a shorter timeout than the server
+ sock = socket.create_connection((host, int(env.https_port)))
+ try:
+ sock.settimeout(0.5)
+ sock.recv(1024)
+ assert False
+ except Exception as ex:
+ print(f"as expected: {ex}")
+ sock.close()
+
+ # Check that mod_reqtimeout handshake setting takes effect
+ def test_h2_105_02(self, env):
+ conf = H2Conf(env)
+ conf.add("""
+ AcceptFilter http none
+ Timeout 10
+ RequestReadTimeout handshake=1 header=5 body=10
+ """)
+ conf.add_vhost_cgi()
+ conf.install()
+ assert env.apache_restart() == 0
+ host = 'localhost'
+ # read with a longer timeout than the server
+ sock = socket.create_connection((host, int(env.https_port)))
+ try:
+ # on some OS, the server does not see our connection until there is
+ # something incoming
+ sock.send(b'0')
+ sock.settimeout(4)
+ buff = sock.recv(1024)
+ assert buff == b''
+ except Exception as ex:
+ print(f"server did not close in time: {ex}")
+ assert False
+ sock.close()
+ # read with a shorter timeout than the server
+ sock = socket.create_connection((host, int(env.https_port)))
+ try:
+ sock.settimeout(0.5)
+ sock.recv(1024)
+ assert False
+ except Exception as ex:
+ print(f"as expected: {ex}")
+ sock.close()
+
+ # Check that mod_reqtimeout handshake setting do no longer apply to handshaked
+ # connections. See <https://github.com/icing/mod_h2/issues/196>.
+ def test_h2_105_03(self, env):
+ conf = H2Conf(env)
+ conf.add("""
+ Timeout 10
+ RequestReadTimeout handshake=1 header=5 body=10
+ """)
+ conf.add_vhost_cgi()
+ conf.install()
+ assert env.apache_restart() == 0
+ url = env.mkurl("https", "cgi", "/necho.py")
+ r = env.curl_get(url, 5, options=[
+ "-vvv",
+ "-F", ("count=%d" % 100),
+ "-F", ("text=%s" % "abcdefghijklmnopqrstuvwxyz"),
+ "-F", ("wait1=%f" % 1.5),
+ ])
+ assert r.response["status"] == 200
+
+ def test_h2_105_10(self, env):
+ # just a check without delays if all is fine
+ conf = H2Conf(env)
+ conf.add_vhost_cgi()
+ conf.install()
+ assert env.apache_restart() == 0
+ url = env.mkurl("https", "cgi", "/h2test/delay")
+ piper = CurlPiper(env=env, url=url)
+ piper.start()
+ stdout, stderr = piper.close()
+ assert piper.exitcode == 0
+ assert len("".join(stdout)) == 3 * 8192
+
+ def test_h2_105_11(self, env):
+ # short connection timeout, longer stream delay
+ # connection timeout must not abort ongoing streams
+ conf = H2Conf(env)
+ conf.add_vhost_cgi()
+ conf.add("Timeout 1")
+ conf.install()
+ assert env.apache_restart() == 0
+ url = env.mkurl("https", "cgi", "/h2test/delay?1200ms")
+ piper = CurlPiper(env=env, url=url)
+ piper.start()
+ stdout, stderr = piper.close()
+ assert len("".join(stdout)) == 3 * 8192
+
+ def test_h2_105_12(self, env):
+ # long connection timeout, short stream timeout
+ # sending a slow POST
+ if env.httpd_is_at_least("2.5.0"):
+ conf = H2Conf(env)
+ conf.add_vhost_cgi()
+ conf.add("Timeout 10")
+ conf.add("H2StreamTimeout 1")
+ conf.install()
+ assert env.apache_restart() == 0
+ url = env.mkurl("https", "cgi", "/h2test/delay?5")
+ piper = CurlPiper(env=env, url=url)
+ piper.start()
+ for _ in range(3):
+ time.sleep(2)
+ try:
+ piper.send("0123456789\n")
+ except BrokenPipeError:
+ break
+ piper.close()
+ assert piper.response
+ assert piper.response['status'] == 408, f"{piper.response}"
diff --git a/test/modules/http2/test_106_shutdown.py b/test/modules/http2/test_106_shutdown.py
new file mode 100644
index 0000000..b119292
--- /dev/null
+++ b/test/modules/http2/test_106_shutdown.py
@@ -0,0 +1,69 @@
+#
+# mod-h2 test suite
+# check HTTP/2 timeout behaviour
+#
+import time
+from threading import Thread
+
+import pytest
+
+from .env import H2Conf, H2TestEnv
+from pyhttpd.result import ExecResult
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestShutdown:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ conf = H2Conf(env)
+ conf.add_vhost_cgi()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ def test_h2_106_01(self, env):
+ url = env.mkurl("https", "cgi", "/necho.py")
+ lines = 100000
+ text = "123456789"
+ wait2 = 1.0
+ self.r = None
+ def long_request():
+ args = ["-vvv",
+ "-F", f"count={lines}",
+ "-F", f"text={text}",
+ "-F", f"wait2={wait2}",
+ ]
+ self.r = env.curl_get(url, 5, options=args)
+
+ t = Thread(target=long_request)
+ t.start()
+ time.sleep(0.5)
+ assert env.apache_reload() == 0
+ t.join()
+ # noinspection PyTypeChecker
+ time.sleep(1)
+ r: ExecResult = self.r
+ assert r.exit_code == 0
+ assert r.response, f"no response via {r.args} in {r.stderr}\nstdout: {len(r.stdout)} bytes"
+ assert r.response["status"] == 200, f"{r}"
+ assert len(r.response["body"]) == (lines * (len(text)+1)), f"{r}"
+
+ def test_h2_106_02(self, env):
+ # PR65731: invalid GOAWAY frame at session start when
+ # MaxRequestsPerChild is reached
+ # Create a low limit and only 2 children, so we'll encounter this easily
+ conf = H2Conf(env, extras={
+ 'base': [
+ "ServerLimit 2",
+ "MaxRequestsPerChild 3"
+ ]
+ })
+ conf.add_vhost_test1()
+ conf.install()
+ assert env.apache_restart() == 0
+ url = env.mkurl("https", "test1", "/index.html")
+ for i in range(7):
+ r = env.curl_get(url, options=['-vvv'])
+ assert r.exit_code == 0, f"failed on {i}. request: {r.stdout} {r.stderr}"
+ assert r.response["status"] == 200
+ assert "HTTP/2" == r.response["protocol"] \ No newline at end of file
diff --git a/test/modules/http2/test_200_header_invalid.py b/test/modules/http2/test_200_header_invalid.py
new file mode 100644
index 0000000..fdbfbe4
--- /dev/null
+++ b/test/modules/http2/test_200_header_invalid.py
@@ -0,0 +1,182 @@
+import pytest
+
+from .env import H2Conf, H2TestEnv
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestInvalidHeaders:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ H2Conf(env).add_vhost_cgi().install()
+ assert env.apache_restart() == 0
+
+ # let the hecho.py CGI echo chars < 0x20 in field name
+ # for almost all such characters, the stream gets aborted with a h2 error and
+ # there will be no http status, cr and lf are handled special
+ def test_h2_200_01(self, env):
+ url = env.mkurl("https", "cgi", "/hecho.py")
+ for x in range(1, 32):
+ r = env.curl_post_data(url, "name=x%%%02xx&value=yz" % x)
+ if x in [10]:
+ assert 0 == r.exit_code, "unexpected exit code for char 0x%02x" % x
+ assert 500 == r.response["status"], "unexpected status for char 0x%02x" % x
+ elif x in [13]:
+ assert 0 == r.exit_code, "unexpected exit code for char 0x%02x" % x
+ assert 200 == r.response["status"], "unexpected status for char 0x%02x" % x
+ else:
+ assert 0 != r.exit_code, "unexpected exit code for char 0x%02x" % x
+
+ # let the hecho.py CGI echo chars < 0x20 in field value
+ # for almost all such characters, the stream gets aborted with a h2 error and
+ # there will be no http status, cr and lf are handled special
+ def test_h2_200_02(self, env):
+ url = env.mkurl("https", "cgi", "/hecho.py")
+ for x in range(1, 32):
+ if 9 != x:
+ r = env.curl_post_data(url, "name=x&value=y%%%02x" % x)
+ if x in [10, 13]:
+ assert 0 == r.exit_code, "unexpected exit code for char 0x%02x" % x
+ assert 200 == r.response["status"], "unexpected status for char 0x%02x" % x
+ else:
+ assert 0 != r.exit_code, "unexpected exit code for char 0x%02x" % x
+
+ # let the hecho.py CGI echo 0x10 and 0x7f in field name and value
+ def test_h2_200_03(self, env):
+ url = env.mkurl("https", "cgi", "/hecho.py")
+ for h in ["10", "7f"]:
+ r = env.curl_post_data(url, "name=x%%%s&value=yz" % h)
+ assert 0 != r.exit_code
+ r = env.curl_post_data(url, "name=x&value=y%%%sz" % h)
+ assert 0 != r.exit_code
+
+ # test header field lengths check, LimitRequestLine (default 8190)
+ def test_h2_200_10(self, env):
+ url = env.mkurl("https", "cgi", "/")
+ val = "1234567890" # 10 chars
+ for i in range(3): # make a 10000 char string
+ val = "%s%s%s%s%s%s%s%s%s%s" % (val, val, val, val, val, val, val, val, val, val)
+ # LimitRequestLine 8190 ok, one more char -> 431
+ r = env.curl_get(url, options=["-H", "x: %s" % (val[:8187])])
+ assert r.response["status"] == 200
+ r = env.curl_get(url, options=["-H", "x: %sx" % (val[:8188])])
+ assert 431 == r.response["status"]
+ # same with field name
+ r = env.curl_get(url, options=["-H", "y%s: 1" % (val[:8186])])
+ assert r.response["status"] == 200
+ r = env.curl_get(url, options=["-H", "y%s: 1" % (val[:8188])])
+ assert 431 == r.response["status"]
+
+ # test header field lengths check, LimitRequestFieldSize (default 8190)
+ def test_h2_200_11(self, env):
+ url = env.mkurl("https", "cgi", "/")
+ val = "1234567890" # 10 chars
+ for i in range(3): # make a 10000 char string
+ val = "%s%s%s%s%s%s%s%s%s%s" % (val, val, val, val, val, val, val, val, val, val)
+ # LimitRequestFieldSize 8190 ok, one more char -> 400 in HTTP/1.1
+ # (we send 4000+4185 since they are concatenated by ", " and start with "x: "
+ r = env.curl_get(url, options=["-H", "x: %s" % (val[:4000]), "-H", "x: %s" % (val[:4185])])
+ assert r.response["status"] == 200
+ r = env.curl_get(url, options=["--http1.1", "-H", "x: %s" % (val[:4000]), "-H", "x: %s" % (val[:4189])])
+ assert 400 == r.response["status"]
+ r = env.curl_get(url, options=["-H", "x: %s" % (val[:4000]), "-H", "x: %s" % (val[:4191])])
+ assert 431 == r.response["status"]
+
+ # test header field count, LimitRequestFields (default 100)
+ # see #201: several headers with same name are mered and count only once
+ def test_h2_200_12(self, env):
+ url = env.mkurl("https", "cgi", "/")
+ opt = []
+ # curl sends 3 headers itself (user-agent, accept, and our AP-Test-Name)
+ for i in range(97):
+ opt += ["-H", "x: 1"]
+ r = env.curl_get(url, options=opt)
+ assert r.response["status"] == 200
+ r = env.curl_get(url, options=(opt + ["-H", "y: 2"]))
+ assert r.response["status"] == 200
+
+ # test header field count, LimitRequestFields (default 100)
+ # different header names count each
+ def test_h2_200_13(self, env):
+ url = env.mkurl("https", "cgi", "/")
+ opt = []
+ # curl sends 3 headers itself (user-agent, accept, and our AP-Test-Name)
+ for i in range(97):
+ opt += ["-H", f"x{i}: 1"]
+ r = env.curl_get(url, options=opt)
+ assert r.response["status"] == 200
+ r = env.curl_get(url, options=(opt + ["-H", "y: 2"]))
+ assert 431 == r.response["status"]
+
+ # test "LimitRequestFields 0" setting, see #200
+ def test_h2_200_14(self, env):
+ conf = H2Conf(env)
+ conf.add("""
+ LimitRequestFields 20
+ """)
+ conf.add_vhost_cgi()
+ conf.install()
+ assert env.apache_restart() == 0
+ url = env.mkurl("https", "cgi", "/")
+ opt = []
+ for i in range(21):
+ opt += ["-H", "x{0}: 1".format(i)]
+ r = env.curl_get(url, options=opt)
+ assert 431 == r.response["status"]
+ conf = H2Conf(env)
+ conf.add("""
+ LimitRequestFields 0
+ """)
+ conf.add_vhost_cgi()
+ conf.install()
+ assert env.apache_restart() == 0
+ url = env.mkurl("https", "cgi", "/")
+ opt = []
+ for i in range(100):
+ opt += ["-H", "x{0}: 1".format(i)]
+ r = env.curl_get(url, options=opt)
+ assert r.response["status"] == 200
+
+ # the uri limits
+ def test_h2_200_15(self, env):
+ conf = H2Conf(env)
+ conf.add("""
+ LimitRequestLine 48
+ """)
+ conf.add_vhost_cgi()
+ conf.install()
+ assert env.apache_restart() == 0
+ url = env.mkurl("https", "cgi", "/")
+ r = env.curl_get(url)
+ assert r.response["status"] == 200
+ url = env.mkurl("https", "cgi", "/" + (48*"x"))
+ r = env.curl_get(url)
+ assert 414 == r.response["status"]
+ # nghttp sends the :method: header first (so far)
+ # trigger a too long request line on it
+ # the stream will RST and we get no response
+ url = env.mkurl("https", "cgi", "/")
+ opt = ["-H:method: {0}".format(100*"x")]
+ r = env.nghttp().get(url, options=opt)
+ assert r.exit_code == 0, r
+ assert not r.response
+
+ # invalid chars in method
+ def test_h2_200_16(self, env):
+ conf = H2Conf(env)
+ conf.add_vhost_cgi()
+ conf.install()
+ assert env.apache_restart() == 0
+ url = env.mkurl("https", "cgi", "/hello.py")
+ opt = ["-H:method: GET /hello.py"]
+ r = env.nghttp().get(url, options=opt)
+ assert r.exit_code == 0, r
+ # nghttp version >= 1.45.0 check pseudo headers and RST streams,
+ # which means we see no response.
+ if r.response is not None:
+ assert r.response["status"] == 400
+ url = env.mkurl("https", "cgi", "/proxy/hello.py")
+ r = env.nghttp().get(url, options=opt)
+ assert r.exit_code == 0, r
+ if r.response is not None:
+ assert r.response["status"] == 400
diff --git a/test/modules/http2/test_201_header_conditional.py b/test/modules/http2/test_201_header_conditional.py
new file mode 100644
index 0000000..f103268
--- /dev/null
+++ b/test/modules/http2/test_201_header_conditional.py
@@ -0,0 +1,70 @@
+import pytest
+
+from .env import H2Conf, H2TestEnv
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestConditionalHeaders:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ H2Conf(env).add(
+ """
+ KeepAlive on
+ MaxKeepAliveRequests 30
+ KeepAliveTimeout 30"""
+ ).add_vhost_test1().install()
+ assert env.apache_restart() == 0
+
+ # check handling of 'if-modified-since' header
+ def test_h2_201_01(self, env):
+ url = env.mkurl("https", "test1", "/006/006.css")
+ r = env.curl_get(url)
+ assert r.response["status"] == 200
+ lm = r.response["header"]["last-modified"]
+ assert lm
+ r = env.curl_get(url, options=["-H", "if-modified-since: %s" % lm])
+ assert 304 == r.response["status"]
+ r = env.curl_get(url, options=["-H", "if-modified-since: Tue, 04 Sep 2010 11:51:59 GMT"])
+ assert r.response["status"] == 200
+
+ # check handling of 'if-none-match' header
+ def test_h2_201_02(self, env):
+ url = env.mkurl("https", "test1", "/006/006.css")
+ r = env.curl_get(url)
+ assert r.response["status"] == 200
+ etag = r.response["header"]["etag"]
+ assert etag
+ r = env.curl_get(url, options=["-H", "if-none-match: %s" % etag])
+ assert 304 == r.response["status"]
+ r = env.curl_get(url, options=["-H", "if-none-match: dummy"])
+ assert r.response["status"] == 200
+
+ @pytest.mark.skipif(True, reason="304 misses the Vary header in trunk and 2.4.x")
+ def test_h2_201_03(self, env):
+ url = env.mkurl("https", "test1", "/006.html")
+ r = env.curl_get(url, options=["-H", "Accept-Encoding: gzip"])
+ assert r.response["status"] == 200
+ for h in r.response["header"]:
+ print("%s: %s" % (h, r.response["header"][h]))
+ lm = r.response["header"]["last-modified"]
+ assert lm
+ assert "gzip" == r.response["header"]["content-encoding"]
+ assert "Accept-Encoding" in r.response["header"]["vary"]
+
+ r = env.curl_get(url, options=["-H", "if-modified-since: %s" % lm,
+ "-H", "Accept-Encoding: gzip"])
+ assert 304 == r.response["status"]
+ for h in r.response["header"]:
+ print("%s: %s" % (h, r.response["header"][h]))
+ assert "vary" in r.response["header"]
+
+ # Check if "Keep-Alive" response header is removed in HTTP/2.
+ def test_h2_201_04(self, env):
+ url = env.mkurl("https", "test1", "/006.html")
+ r = env.curl_get(url, options=["--http1.1", "-H", "Connection: keep-alive"])
+ assert r.response["status"] == 200
+ assert "timeout=30, max=30" == r.response["header"]["keep-alive"]
+ r = env.curl_get(url, options=["-H", "Connection: keep-alive"])
+ assert r.response["status"] == 200
+ assert "keep-alive" not in r.response["header"]
diff --git a/test/modules/http2/test_202_trailer.py b/test/modules/http2/test_202_trailer.py
new file mode 100644
index 0000000..4b4fc42
--- /dev/null
+++ b/test/modules/http2/test_202_trailer.py
@@ -0,0 +1,92 @@
+import os
+import pytest
+
+from .env import H2Conf
+
+
+def setup_data(env):
+ s100 = "012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678\n"
+ with open(os.path.join(env.gen_dir, "data-1k"), 'w') as f:
+ for i in range(10):
+ f.write(s100)
+
+
+# The trailer tests depend on "nghttp" as no other client seems to be able to send those
+# rare things.
+class TestTrailers:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ setup_data(env)
+ conf = H2Conf(env, extras={
+ f"cgi.{env.http_tld}": [
+ "<Location \"/h2test/trailer\">",
+ " SetHandler h2test-trailer",
+ "</Location>"
+ ],
+ })
+ conf.add_vhost_cgi(h2proxy_self=True)
+ conf.install()
+ assert env.apache_restart() == 0
+
+ # check if the server survives a trailer or two
+ def test_h2_202_01(self, env):
+ url = env.mkurl("https", "cgi", "/echo.py")
+ fpath = os.path.join(env.gen_dir, "data-1k")
+ r = env.nghttp().upload(url, fpath, options=["--trailer", "test: 1"])
+ assert r.response["status"] < 300
+ assert len(r.response["body"]) == 1000
+
+ r = env.nghttp().upload(url, fpath, options=["--trailer", "test: 1b", "--trailer", "XXX: test"])
+ assert r.response["status"] < 300
+ assert len(r.response["body"]) == 1000
+
+ # check if the server survives a trailer without content-length
+ def test_h2_202_02(self, env):
+ url = env.mkurl("https", "cgi", "/echo.py")
+ fpath = os.path.join(env.gen_dir, "data-1k")
+ r = env.nghttp().upload(url, fpath, options=["--trailer", "test: 2", "--no-content-length"])
+ assert r.response["status"] < 300
+ assert len(r.response["body"]) == 1000
+
+ # check if echoing request headers in response from GET works
+ def test_h2_202_03(self, env):
+ url = env.mkurl("https", "cgi", "/echohd.py?name=X")
+ r = env.nghttp().get(url, options=["--header", "X: 3"])
+ assert r.response["status"] < 300
+ assert r.response["body"] == b"X: 3\n"
+
+ # check if echoing request headers in response from POST works
+ def test_h2_202_03b(self, env):
+ url = env.mkurl("https", "cgi", "/echohd.py?name=X")
+ r = env.nghttp().post_name(url, "Y", options=["--header", "X: 3b"])
+ assert r.response["status"] < 300
+ assert r.response["body"] == b"X: 3b\n"
+
+ # check if echoing request headers in response from POST works, but trailers are not seen
+ # This is the way CGI invocation works.
+ def test_h2_202_04(self, env):
+ url = env.mkurl("https", "cgi", "/echohd.py?name=X")
+ r = env.nghttp().post_name(url, "Y", options=["--header", "X: 4a", "--trailer", "X: 4b"])
+ assert r.response["status"] < 300
+ assert r.response["body"] == b"X: 4a\n"
+
+ # check that our h2test-trailer handler works
+ def test_h2_202_10(self, env):
+ url = env.mkurl("https", "cgi", "/h2test/trailer?1024")
+ r = env.nghttp().get(url)
+ assert r.response["status"] == 200
+ assert len(r.response["body"]) == 1024
+ assert 'trailer' in r.response
+ assert 'trailer-content-length' in r.response['trailer']
+ assert r.response['trailer']['trailer-content-length'] == '1024'
+
+ # check that trailers also for with empty bodies
+ def test_h2_202_11(self, env):
+ url = env.mkurl("https", "cgi", "/h2test/trailer?0")
+ r = env.nghttp().get(url)
+ assert r.response["status"] == 200
+ assert len(r.response["body"]) == 0, f'{r.response["body"]}'
+ assert 'trailer' in r.response
+ assert 'trailer-content-length' in r.response['trailer']
+ assert r.response['trailer']['trailer-content-length'] == '0'
diff --git a/test/modules/http2/test_203_rfc9113.py b/test/modules/http2/test_203_rfc9113.py
new file mode 100644
index 0000000..9fc8f3b
--- /dev/null
+++ b/test/modules/http2/test_203_rfc9113.py
@@ -0,0 +1,56 @@
+import pytest
+
+from pyhttpd.env import HttpdTestEnv
+from .env import H2Conf
+
+
+class TestRfc9113:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ H2Conf(env).add_vhost_test1().install()
+ assert env.apache_restart() == 0
+
+ # by default, we accept leading/trailing ws in request fields
+ def test_h2_203_01_ws_ignore(self, env):
+ url = env.mkurl("https", "test1", "/")
+ r = env.curl_get(url, options=['-H', 'trailing-space: must not '])
+ assert r.exit_code == 0, f'curl output: {r.stderr}'
+ assert r.response["status"] == 200, f'curl output: {r.stdout}'
+ r = env.curl_get(url, options=['-H', 'trailing-space: must not\t'])
+ assert r.exit_code == 0, f'curl output: {r.stderr}'
+ assert r.response["status"] == 200, f'curl output: {r.stdout}'
+
+ # response header are also handled, but we strip ws before sending
+ @pytest.mark.parametrize(["hvalue", "expvalue", "status"], [
+ ['"123"', '123', 200],
+ ['"123 "', '123', 200], # trailing space stripped
+ ['"123\t"', '123', 200], # trailing tab stripped
+ ['" 123"', '123', 200], # leading space is stripped
+ ['" 123"', '123', 200], # leading spaces are stripped
+ ['"\t123"', '123', 200], # leading tab is stripped
+ ['"expr=%{unescape:123%0A 123}"', '', 500], # illegal char
+ ['" \t "', '', 200], # just ws
+ ])
+ def test_h2_203_02(self, env, hvalue, expvalue, status):
+ hname = 'ap-test-007'
+ conf = H2Conf(env, extras={
+ f'test1.{env.http_tld}': [
+ '<Location /index.html>',
+ f'Header add {hname} {hvalue}',
+ '</Location>',
+ ]
+ })
+ conf.add_vhost_test1(proxy_self=True)
+ conf.install()
+ assert env.apache_restart() == 0
+ url = env.mkurl("https", "test1", "/index.html")
+ r = env.curl_get(url, options=['--http2'])
+ if status == 500 and r.exit_code != 0:
+ # in 2.4.x we fail late on control chars in a response
+ # and RST_STREAM. That's also ok
+ return
+ assert r.response["status"] == status
+ if int(status) < 400:
+ assert r.response["header"][hname] == expvalue
+
diff --git a/test/modules/http2/test_300_interim.py b/test/modules/http2/test_300_interim.py
new file mode 100644
index 0000000..774ab88
--- /dev/null
+++ b/test/modules/http2/test_300_interim.py
@@ -0,0 +1,40 @@
+import pytest
+
+from .env import H2Conf, H2TestEnv
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestInterimResponses:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ H2Conf(env).add_vhost_test1().add_vhost_cgi().install()
+ assert env.apache_restart() == 0
+
+ def setup_method(self, method):
+ print("setup_method: %s" % method.__name__)
+
+ def teardown_method(self, method):
+ print("teardown_method: %s" % method.__name__)
+
+ # check that we normally do not see an interim response
+ def test_h2_300_01(self, env):
+ url = env.mkurl("https", "test1", "/index.html")
+ r = env.curl_post_data(url, 'XYZ')
+ assert r.response["status"] == 200
+ assert "previous" not in r.response
+
+ # check that we see an interim response when we ask for it
+ def test_h2_300_02(self, env):
+ url = env.mkurl("https", "cgi", "/echo.py")
+ r = env.curl_post_data(url, 'XYZ', options=["-H", "expect: 100-continue"])
+ assert r.response["status"] == 200
+ assert "previous" in r.response
+ assert 100 == r.response["previous"]["status"]
+
+ # check proper answer on unexpected
+ def test_h2_300_03(self, env):
+ url = env.mkurl("https", "cgi", "/echo.py")
+ r = env.curl_post_data(url, 'XYZ', options=["-H", "expect: the-unexpected"])
+ assert 417 == r.response["status"]
+ assert "previous" not in r.response
diff --git a/test/modules/http2/test_400_push.py b/test/modules/http2/test_400_push.py
new file mode 100644
index 0000000..9c61608
--- /dev/null
+++ b/test/modules/http2/test_400_push.py
@@ -0,0 +1,200 @@
+import os
+import pytest
+
+from .env import H2Conf, H2TestEnv
+
+
+# The push tests depend on "nghttp"
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestPush:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ H2Conf(env).start_vhost(domains=[f"push.{env.http_tld}"],
+ port=env.https_port, doc_root="htdocs/test1"
+ ).add(r"""
+ RewriteEngine on
+ RewriteRule ^/006-push(.*)?\.html$ /006.html
+ <Location /006-push.html>
+ Header add Link "</006/006.css>;rel=preload"
+ Header add Link "</006/006.js>;rel=preloadX"
+ </Location>
+ <Location /006-push2.html>
+ Header add Link "</006/006.css>;rel=preloadX, </006/006.js>; rel=preload"
+ </Location>
+ <Location /006-push3.html>
+ Header add Link "</006/006.css>;rel=preloa,</006/006.js>;rel=preload"
+ </Location>
+ <Location /006-push4.html>
+ Header add Link "</006/006.css;rel=preload, </006/006.js>; preload"
+ </Location>
+ <Location /006-push5.html>
+ Header add Link '</006/006.css>;rel="preload push"'
+ </Location>
+ <Location /006-push6.html>
+ Header add Link '</006/006.css>;rel="push preload"'
+ </Location>
+ <Location /006-push7.html>
+ Header add Link '</006/006.css>;rel="abc preload push"'
+ </Location>
+ <Location /006-push8.html>
+ Header add Link '</006/006.css>;rel="preload"; nopush'
+ </Location>
+ <Location /006-push20.html>
+ H2PushResource "/006/006.css" critical
+ H2PushResource "/006/006.js"
+ </Location>
+ <Location /006-push30.html>
+ H2Push off
+ Header add Link '</006/006.css>;rel="preload"'
+ </Location>
+ <Location /006-push31.html>
+ H2PushResource "/006/006.css" critical
+ </Location>
+ <Location /006-push32.html>
+ Header add Link "</006/006.css>;rel=preload"
+ </Location>
+ """).end_vhost(
+ ).install()
+ assert env.apache_restart() == 0
+
+ ############################
+ # Link: header handling, various combinations
+
+ # plain resource without configured pushes
+ def test_h2_400_00(self, env):
+ url = env.mkurl("https", "push", "/006.html")
+ r = env.nghttp().get(url)
+ assert r.response["status"] == 200
+ promises = r.results["streams"][r.response["id"]]["promises"]
+ assert 0 == len(promises)
+
+ # 2 link headers configured, only 1 triggers push
+ def test_h2_400_01(self, env):
+ url = env.mkurl("https", "push", "/006-push.html")
+ r = env.nghttp().get(url, options=["-Haccept-encoding: none"])
+ assert r.response["status"] == 200
+ promises = r.results["streams"][r.response["id"]]["promises"]
+ assert 1 == len(promises)
+ assert '/006/006.css' == promises[0]["request"]["header"][":path"]
+ assert 216 == len(promises[0]["response"]["body"])
+
+ # Same as 400_01, but with single header line configured
+ def test_h2_400_02(self, env):
+ url = env.mkurl("https", "push", "/006-push2.html")
+ r = env.nghttp().get(url)
+ assert r.response["status"] == 200
+ promises = r.results["streams"][r.response["id"]]["promises"]
+ assert 1 == len(promises)
+ assert '/006/006.js' == promises[0]["request"]["header"][":path"]
+
+ # 2 Links, only one with correct rel attribute
+ def test_h2_400_03(self, env):
+ url = env.mkurl("https", "push", "/006-push3.html")
+ r = env.nghttp().get(url)
+ assert r.response["status"] == 200
+ promises = r.results["streams"][r.response["id"]]["promises"]
+ assert 1 == len(promises)
+ assert '/006/006.js' == promises[0]["request"]["header"][":path"]
+
+ # Missing > in Link header, PUSH not triggered
+ def test_h2_400_04(self, env):
+ url = env.mkurl("https", "push", "/006-push4.html")
+ r = env.nghttp().get(url)
+ assert r.response["status"] == 200
+ promises = r.results["streams"][r.response["id"]]["promises"]
+ assert 0 == len(promises)
+
+ # More than one value in "rel" parameter
+ def test_h2_400_05(self, env):
+ url = env.mkurl("https", "push", "/006-push5.html")
+ r = env.nghttp().get(url)
+ assert r.response["status"] == 200
+ promises = r.results["streams"][r.response["id"]]["promises"]
+ assert 1 == len(promises)
+ assert '/006/006.css' == promises[0]["request"]["header"][":path"]
+
+ # Another "rel" parameter variation
+ def test_h2_400_06(self, env):
+ url = env.mkurl("https", "push", "/006-push6.html")
+ r = env.nghttp().get(url)
+ assert r.response["status"] == 200
+ promises = r.results["streams"][r.response["id"]]["promises"]
+ assert 1 == len(promises)
+ assert '/006/006.css' == promises[0]["request"]["header"][":path"]
+
+ # Another "rel" parameter variation
+ def test_h2_400_07(self, env):
+ url = env.mkurl("https", "push", "/006-push7.html")
+ r = env.nghttp().get(url)
+ assert r.response["status"] == 200
+ promises = r.results["streams"][r.response["id"]]["promises"]
+ assert 1 == len(promises)
+ assert '/006/006.css' == promises[0]["request"]["header"][":path"]
+
+ # Pushable link header with "nopush" attribute
+ def test_h2_400_08(self, env):
+ url = env.mkurl("https", "push", "/006-push8.html")
+ r = env.nghttp().get(url)
+ assert r.response["status"] == 200
+ promises = r.results["streams"][r.response["id"]]["promises"]
+ assert 0 == len(promises)
+
+ # 2 H2PushResource config trigger on GET, but not on POST
+ def test_h2_400_20(self, env):
+ url = env.mkurl("https", "push", "/006-push20.html")
+ r = env.nghttp().get(url)
+ assert r.response["status"] == 200
+ promises = r.results["streams"][r.response["id"]]["promises"]
+ assert 2 == len(promises)
+
+ fpath = os.path.join(env.gen_dir, "data-400-20")
+ with open(fpath, 'w') as f:
+ f.write("test upload data")
+ r = env.nghttp().upload(url, fpath)
+ assert r.response["status"] == 200
+ promises = r.results["streams"][r.response["id"]]["promises"]
+ assert 0 == len(promises)
+
+ # H2Push configured Off in location
+ def test_h2_400_30(self, env):
+ url = env.mkurl("https", "push", "/006-push30.html")
+ r = env.nghttp().get(url)
+ assert r.response["status"] == 200
+ promises = r.results["streams"][r.response["id"]]["promises"]
+ assert 0 == len(promises)
+
+ # - suppress PUSH
+ def test_h2_400_50(self, env):
+ url = env.mkurl("https", "push", "/006-push.html")
+ r = env.nghttp().get(url, options=['-H', 'accept-push-policy: none'])
+ assert r.response["status"] == 200
+ promises = r.results["streams"][r.response["id"]]["promises"]
+ assert 0 == len(promises)
+
+ # - default pushes desired
+ def test_h2_400_51(self, env):
+ url = env.mkurl("https", "push", "/006-push.html")
+ r = env.nghttp().get(url, options=['-H', 'accept-push-policy: default'])
+ assert r.response["status"] == 200
+ promises = r.results["streams"][r.response["id"]]["promises"]
+ assert 1 == len(promises)
+
+ # - HEAD pushes desired
+ def test_h2_400_52(self, env):
+ url = env.mkurl("https", "push", "/006-push.html")
+ r = env.nghttp().get(url, options=['-H', 'accept-push-policy: head'])
+ assert r.response["status"] == 200
+ promises = r.results["streams"][r.response["id"]]["promises"]
+ assert 1 == len(promises)
+ assert '/006/006.css' == promises[0]["request"]["header"][":path"]
+ assert b"" == promises[0]["response"]["body"]
+ assert 0 == len(promises[0]["response"]["body"])
+
+ # - fast-load pushes desired
+ def test_h2_400_53(self, env):
+ url = env.mkurl("https", "push", "/006-push.html")
+ r = env.nghttp().get(url, options=['-H', 'accept-push-policy: fast-load'])
+ assert r.response["status"] == 200
+ promises = r.results["streams"][r.response["id"]]["promises"]
+ assert 1 == len(promises)
diff --git a/test/modules/http2/test_401_early_hints.py b/test/modules/http2/test_401_early_hints.py
new file mode 100644
index 0000000..f73dcc4
--- /dev/null
+++ b/test/modules/http2/test_401_early_hints.py
@@ -0,0 +1,47 @@
+import pytest
+
+from .env import H2Conf, H2TestEnv
+
+
+# The push tests depend on "nghttp"
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestEarlyHints:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ H2Conf(env).start_vhost(domains=[f"hints.{env.http_tld}"],
+ port=env.https_port, doc_root="htdocs/test1"
+ ).add("""
+ H2EarlyHints on
+ RewriteEngine on
+ RewriteRule ^/006-(.*)?\\.html$ /006.html
+ <Location /006-hints.html>
+ H2PushResource "/006/006.css" critical
+ </Location>
+ <Location /006-nohints.html>
+ Header add Link "</006/006.css>;rel=preload"
+ </Location>
+ """).end_vhost(
+ ).install()
+ assert env.apache_restart() == 0
+
+ # H2EarlyHints enabled in general, check that it works for H2PushResource
+ def test_h2_401_31(self, env, repeat):
+ url = env.mkurl("https", "hints", "/006-hints.html")
+ r = env.nghttp().get(url)
+ assert r.response["status"] == 200
+ promises = r.results["streams"][r.response["id"]]["promises"]
+ assert 1 == len(promises)
+ early = r.response["previous"]
+ assert early
+ assert 103 == int(early["header"][":status"])
+ assert early["header"]["link"]
+
+ # H2EarlyHints enabled in general, but does not trigger on added response headers
+ def test_h2_401_32(self, env, repeat):
+ url = env.mkurl("https", "hints", "/006-nohints.html")
+ r = env.nghttp().get(url)
+ assert r.response["status"] == 200
+ promises = r.results["streams"][r.response["id"]]["promises"]
+ assert 1 == len(promises)
+ assert "previous" not in r.response
diff --git a/test/modules/http2/test_500_proxy.py b/test/modules/http2/test_500_proxy.py
new file mode 100644
index 0000000..2e61415
--- /dev/null
+++ b/test/modules/http2/test_500_proxy.py
@@ -0,0 +1,151 @@
+import inspect
+import os
+import re
+import pytest
+
+from .env import H2Conf, H2TestEnv
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestProxy:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ H2Conf(env).add_vhost_cgi(proxy_self=True).install()
+ assert env.apache_restart() == 0
+
+ def local_src(self, fname):
+ return os.path.join(os.path.dirname(inspect.getfile(TestProxy)), fname)
+
+ def setup_method(self, method):
+ print("setup_method: %s" % method.__name__)
+
+ def teardown_method(self, method):
+ print("teardown_method: %s" % method.__name__)
+
+ def test_h2_500_01(self, env):
+ url = env.mkurl("https", "cgi", "/proxy/hello.py")
+ r = env.curl_get(url, 5)
+ assert r.response["status"] == 200
+ assert "HTTP/1.1" == r.response["json"]["protocol"]
+ assert r.response["json"]["https"] == ""
+ assert r.response["json"]["ssl_protocol"] == ""
+ assert r.response["json"]["h2"] == ""
+ assert r.response["json"]["h2push"] == ""
+
+ # upload and GET again using curl, compare to original content
+ def curl_upload_and_verify(self, env, fname, options=None):
+ url = env.mkurl("https", "cgi", "/proxy/upload.py")
+ fpath = os.path.join(env.gen_dir, fname)
+ r = env.curl_upload(url, fpath, options=options)
+ assert r.exit_code == 0
+ assert 200 <= r.response["status"] < 300
+
+ # why is the scheme wrong?
+ r2 = env.curl_get(re.sub(r'http:', 'https:', r.response["header"]["location"]))
+ assert r2.exit_code == 0
+ assert r2.response["status"] == 200
+ with open(self.local_src(fpath), mode='rb') as file:
+ src = file.read()
+ assert r2.response["body"] == src
+
+ def test_h2_500_10(self, env, repeat):
+ self.curl_upload_and_verify(env, "data-1k", ["--http2"])
+ self.curl_upload_and_verify(env, "data-10k", ["--http2"])
+ self.curl_upload_and_verify(env, "data-100k", ["--http2"])
+ self.curl_upload_and_verify(env, "data-1m", ["--http2"])
+
+ # POST some data using nghttp and see it echo'ed properly back
+ def nghttp_post_and_verify(self, env, fname, options=None):
+ url = env.mkurl("https", "cgi", "/proxy/echo.py")
+ fpath = os.path.join(env.gen_dir, fname)
+ r = env.nghttp().upload(url, fpath, options=options)
+ assert r.exit_code == 0
+ assert 200 <= r.response["status"] < 300
+ with open(self.local_src(fpath), mode='rb') as file:
+ src = file.read()
+ if r.response["body"] != src:
+ with open(os.path.join(env.gen_dir, "nghttp.out"), 'w') as fd:
+ fd.write(r.outraw.decode())
+ fd.write("\nstderr:\n")
+ fd.write(r.stderr)
+ assert r.response["body"] == src
+
+ def test_h2_500_20(self, env, repeat):
+ self.nghttp_post_and_verify(env, "data-1k", [])
+ self.nghttp_post_and_verify(env, "data-10k", [])
+ self.nghttp_post_and_verify(env, "data-100k", [])
+ self.nghttp_post_and_verify(env, "data-1m", [])
+
+ def test_h2_500_21(self, env, repeat):
+ self.nghttp_post_and_verify(env, "data-1k", ["--no-content-length"])
+ self.nghttp_post_and_verify(env, "data-10k", ["--no-content-length"])
+ self.nghttp_post_and_verify(env, "data-100k", ["--no-content-length"])
+ self.nghttp_post_and_verify(env, "data-1m", ["--no-content-length"])
+
+ # upload and GET again using nghttp, compare to original content
+ def nghttp_upload_and_verify(self, env, fname, options=None):
+ url = env.mkurl("https", "cgi", "/proxy/upload.py")
+ fpath = os.path.join(env.gen_dir, fname)
+
+ r = env.nghttp().upload_file(url, fpath, options=options)
+ assert r.exit_code == 0
+ assert 200 <= r.response["status"] < 300
+ assert r.response["header"]["location"]
+
+ # why is the scheme wrong?
+ r2 = env.nghttp().get(re.sub(r'http:', 'https:', r.response["header"]["location"]))
+ assert r2.exit_code == 0
+ assert r2.response["status"] == 200
+ with open(self.local_src(fpath), mode='rb') as file:
+ src = file.read()
+ assert src == r2.response["body"]
+
+ def test_h2_500_22(self, env):
+ self.nghttp_upload_and_verify(env, "data-1k", [])
+ self.nghttp_upload_and_verify(env, "data-10k", [])
+ self.nghttp_upload_and_verify(env, "data-100k", [])
+ self.nghttp_upload_and_verify(env, "data-1m", [])
+
+ def test_h2_500_23(self, env):
+ self.nghttp_upload_and_verify(env, "data-1k", ["--no-content-length"])
+ self.nghttp_upload_and_verify(env, "data-10k", ["--no-content-length"])
+ self.nghttp_upload_and_verify(env, "data-100k", ["--no-content-length"])
+ self.nghttp_upload_and_verify(env, "data-1m", ["--no-content-length"])
+
+ # upload using nghttp and check returned status
+ def nghttp_upload_stat(self, env, fname, options=None):
+ url = env.mkurl("https", "cgi", "/proxy/upload.py")
+ fpath = os.path.join(env.gen_dir, fname)
+
+ r = env.nghttp().upload_file(url, fpath, options=options)
+ assert r.exit_code == 0
+ assert 200 <= r.response["status"] < 300
+ assert r.response["header"]["location"]
+
+ def test_h2_500_24(self, env):
+ for i in range(100):
+ self.nghttp_upload_stat(env, "data-1k", ["--no-content-length"])
+
+ # lets do some error tests
+ def test_h2_500_30(self, env):
+ url = env.mkurl("https", "cgi", "/proxy/h2test/error?status=500")
+ r = env.curl_get(url)
+ assert r.exit_code == 0, r
+ assert r.response['status'] == 500
+ url = env.mkurl("https", "cgi", "/proxy/h2test/error?error=timeout")
+ r = env.curl_get(url)
+ assert r.exit_code == 0, r
+ assert r.response['status'] == 408
+
+ # produce an error during response body
+ def test_h2_500_31(self, env, repeat):
+ url = env.mkurl("https", "cgi", "/proxy/h2test/error?body_error=timeout")
+ r = env.curl_get(url)
+ assert r.exit_code != 0, r
+
+ # produce an error, fail to generate an error bucket
+ def test_h2_500_32(self, env, repeat):
+ url = env.mkurl("https", "cgi", "/proxy/h2test/error?body_error=timeout&error_bucket=0")
+ r = env.curl_get(url)
+ assert r.exit_code != 0, r
diff --git a/test/modules/http2/test_501_proxy_serverheader.py b/test/modules/http2/test_501_proxy_serverheader.py
new file mode 100644
index 0000000..0d7c188
--- /dev/null
+++ b/test/modules/http2/test_501_proxy_serverheader.py
@@ -0,0 +1,36 @@
+import pytest
+
+from .env import H2Conf, H2TestEnv
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestProxyServerHeader:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ conf = H2Conf(env, extras={
+ f'cgi.{env.http_tld}': [
+ "Header unset Server",
+ "Header always set Server cgi",
+ ]
+ })
+ conf.add_vhost_cgi(proxy_self=True, h2proxy_self=False)
+ conf.install()
+ assert env.apache_restart() == 0
+
+ def setup_method(self, method):
+ print("setup_method: %s" % method.__name__)
+
+ def teardown_method(self, method):
+ print("teardown_method: %s" % method.__name__)
+
+ def test_h2_501_01(self, env):
+ url = env.mkurl("https", "cgi", "/proxy/hello.py")
+ r = env.curl_get(url, 5)
+ assert r.response["status"] == 200
+ assert "HTTP/1.1" == r.response["json"]["protocol"]
+ assert "" == r.response["json"]["https"]
+ assert "" == r.response["json"]["ssl_protocol"]
+ assert "" == r.response["json"]["h2"]
+ assert "" == r.response["json"]["h2push"]
+ assert "cgi" == r.response["header"]["server"]
diff --git a/test/modules/http2/test_502_proxy_port.py b/test/modules/http2/test_502_proxy_port.py
new file mode 100644
index 0000000..f6c6db1
--- /dev/null
+++ b/test/modules/http2/test_502_proxy_port.py
@@ -0,0 +1,41 @@
+import pytest
+
+from .env import H2Conf, H2TestEnv
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestProxyPort:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ conf = H2Conf(env, extras={
+ 'base': [
+ f'Listen {env.proxy_port}',
+ 'Protocols h2c http/1.1',
+ 'LogLevel proxy_http2:trace2 proxy:trace2',
+ ],
+ f'cgi.{env.http_tld}': [
+ "Header unset Server",
+ "Header always set Server cgi",
+ ]
+ })
+ conf.add_vhost_cgi(proxy_self=False, h2proxy_self=False)
+ conf.start_vhost(domains=[f"test1.{env.http_tld}"], port=env.proxy_port)
+ conf.add([
+ 'Protocols h2c',
+ 'RewriteEngine On',
+ 'RewriteRule "^/(.*)$" "h2c://%{HTTP_HOST}/$1"[NC,P]',
+ 'ProxyPassMatch / "h2c://$1/"',
+ ])
+ conf.end_vhost()
+ conf.install()
+ assert env.apache_restart() == 0
+
+ # Test PR 65881
+ # h2c upgraded request via a dynamic proxy onto another port
+ def test_h2_502_01(self, env):
+ url = f'http://localhost:{env.http_port}/hello.py'
+ r = env.curl_get(url, 5, options=['--http2',
+ '--proxy', f'localhost:{env.proxy_port}'])
+ assert r.response['status'] == 200
+ assert r.json['port'] == f'{env.http_port}'
diff --git a/test/modules/http2/test_600_h2proxy.py b/test/modules/http2/test_600_h2proxy.py
new file mode 100644
index 0000000..e93ba1a
--- /dev/null
+++ b/test/modules/http2/test_600_h2proxy.py
@@ -0,0 +1,175 @@
+import pytest
+
+from .env import H2Conf, H2TestEnv
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestH2Proxy:
+
+ def test_h2_600_01(self, env):
+ conf = H2Conf(env, extras={
+ f'cgi.{env.http_tld}': [
+ "SetEnvIf Host (.+) X_HOST=$1",
+ ]
+ })
+ conf.add_vhost_cgi(h2proxy_self=True)
+ conf.install()
+ assert env.apache_restart() == 0
+ url = env.mkurl("https", "cgi", "/h2proxy/hello.py")
+ r = env.curl_get(url, 5)
+ assert r.response["status"] == 200
+ assert r.response["json"]["protocol"] == "HTTP/2.0"
+ assert r.response["json"]["https"] == "on"
+ assert r.response["json"]["ssl_protocol"] != ""
+ assert r.response["json"]["h2"] == "on"
+ assert r.response["json"]["h2push"] == "off"
+ assert r.response["json"]["host"] == f"cgi.{env.http_tld}:{env.https_port}"
+
+ def test_h2_600_02(self, env):
+ conf = H2Conf(env, extras={
+ f'cgi.{env.http_tld}': [
+ "SetEnvIf Host (.+) X_HOST=$1",
+ f"ProxyPreserveHost on",
+ f"ProxyPass /h2c/ h2c://127.0.0.1:{env.http_port}/",
+ ]
+ })
+ conf.add_vhost_cgi()
+ conf.install()
+ assert env.apache_restart() == 0
+ url = env.mkurl("https", "cgi", "/h2c/hello.py")
+ r = env.curl_get(url, 5)
+ assert r.response["status"] == 200
+ assert r.response["json"]["protocol"] == "HTTP/2.0"
+ assert r.response["json"]["https"] == ""
+ # the proxied backend sees Host header as passed on front
+ assert r.response["json"]["host"] == f"cgi.{env.http_tld}:{env.https_port}"
+ assert r.response["json"]["h2_original_host"] == ""
+
+ def test_h2_600_03(self, env):
+ conf = H2Conf(env, extras={
+ f'cgi.{env.http_tld}': [
+ "SetEnvIf Host (.+) X_HOST=$1",
+ f"ProxyPreserveHost off",
+ f"ProxyPass /h2c/ h2c://127.0.0.1:{env.http_port}/",
+ ]
+ })
+ conf.add_vhost_cgi()
+ conf.install()
+ assert env.apache_restart() == 0
+ url = env.mkurl("https", "cgi", "/h2c/hello.py")
+ r = env.curl_get(url, 5)
+ assert r.response["status"] == 200
+ assert r.response["json"]["protocol"] == "HTTP/2.0"
+ assert r.response["json"]["https"] == ""
+ # the proxied backend sees Host as using in connecting to it
+ assert r.response["json"]["host"] == f"127.0.0.1:{env.http_port}"
+ assert r.response["json"]["h2_original_host"] == ""
+
+ # check that connection reuse actually happens as configured
+ @pytest.mark.parametrize("enable_reuse", [ "on", "off" ])
+ def test_h2_600_04(self, env, enable_reuse):
+ conf = H2Conf(env, extras={
+ f'cgi.{env.http_tld}': [
+ f"ProxyPassMatch ^/h2proxy/([0-9]+)/(.*)$ "
+ f" h2c://127.0.0.1:$1/$2 enablereuse={enable_reuse} keepalive=on",
+ ]
+ })
+ conf.add_vhost_cgi()
+ conf.install()
+ assert env.apache_restart() == 0
+ url = env.mkurl("https", "cgi", f"/h2proxy/{env.http_port}/hello.py")
+ if enable_reuse == "on":
+ # reuse is not guaranteed for each request, but we expect some
+ # to do it and run on a h2 stream id > 1
+ reused = False
+ count = 10
+ r = env.curl_raw([url] * count, 5)
+ response = r.response
+ for n in range(count):
+ assert response["status"] == 200
+ if n == (count - 1):
+ break
+ response = response["previous"]
+ assert r.json[0]["h2_stream_id"] == "1"
+ for n in range(1, count):
+ if int(r.json[n]["h2_stream_id"]) > 1:
+ reused = True
+ break
+ assert reused
+ else:
+ r = env.curl_raw([url, url], 5)
+ assert r.response["previous"]["status"] == 200
+ assert r.response["status"] == 200
+ assert r.json[0]["h2_stream_id"] == "1"
+ assert r.json[1]["h2_stream_id"] == "1"
+
+ # do some flexible setup from #235 to proper connection selection
+ @pytest.mark.parametrize("enable_reuse", [ "on", "off" ])
+ def test_h2_600_05(self, env, enable_reuse):
+ conf = H2Conf(env, extras={
+ f'cgi.{env.http_tld}': [
+ f"ProxyPassMatch ^/h2proxy/([0-9]+)/(.*)$ "
+ f" h2c://127.0.0.1:$1/$2 enablereuse={enable_reuse} keepalive=on",
+ ]
+ })
+ conf.add_vhost_cgi()
+ conf.add([
+ f'Listen {env.http_port2}',
+ 'UseCanonicalName On',
+ 'UseCanonicalPhysicalPort On'
+ ])
+ conf.start_vhost(domains=[f'cgi.{env.http_tld}'],
+ port=5004, doc_root="htdocs/cgi")
+ conf.add("AddHandler cgi-script .py")
+ conf.end_vhost()
+ conf.install()
+ assert env.apache_restart() == 0
+ url = env.mkurl("https", "cgi", f"/h2proxy/{env.http_port}/hello.py")
+ url2 = env.mkurl("https", "cgi", f"/h2proxy/{env.http_port2}/hello.py")
+ r = env.curl_raw([url, url2], 5)
+ assert r.response["previous"]["status"] == 200
+ assert int(r.json[0]["port"]) == env.http_port
+ assert r.response["status"] == 200
+ exp_port = env.http_port if enable_reuse == "on" else env.http_port2
+ assert int(r.json[1]["port"]) == exp_port
+
+ # lets do some error tests
+ def test_h2_600_30(self, env):
+ conf = H2Conf(env)
+ conf.add_vhost_cgi(h2proxy_self=True)
+ conf.install()
+ assert env.apache_restart() == 0
+ url = env.mkurl("https", "cgi", "/h2proxy/h2test/error?status=500")
+ r = env.curl_get(url)
+ assert r.exit_code == 0, r
+ assert r.response['status'] == 500
+ url = env.mkurl("https", "cgi", "/h2proxy/h2test/error?error=timeout")
+ r = env.curl_get(url)
+ assert r.exit_code == 0, r
+ assert r.response['status'] == 408
+
+ # produce an error during response body
+ def test_h2_600_31(self, env, repeat):
+ conf = H2Conf(env)
+ conf.add_vhost_cgi(h2proxy_self=True)
+ conf.install()
+ assert env.apache_restart() == 0
+ url = env.mkurl("https", "cgi", "/h2proxy/h2test/error?body_error=timeout")
+ r = env.curl_get(url)
+ # depending on when the error is detect in proxying, if may RST the
+ # stream (exit_code != 0) or give a 503 response.
+ if r.exit_code == 0:
+ assert r.response['status'] == 503
+
+ # produce an error, fail to generate an error bucket
+ def test_h2_600_32(self, env, repeat):
+ conf = H2Conf(env)
+ conf.add_vhost_cgi(h2proxy_self=True)
+ conf.install()
+ assert env.apache_restart() == 0
+ url = env.mkurl("https", "cgi", "/h2proxy/h2test/error?body_error=timeout&error_bucket=0")
+ r = env.curl_get(url)
+ # depending on when the error is detect in proxying, if may RST the
+ # stream (exit_code != 0) or give a 503 response.
+ if r.exit_code == 0:
+ assert r.response['status'] == 503
diff --git a/test/modules/http2/test_700_load_get.py b/test/modules/http2/test_700_load_get.py
new file mode 100644
index 0000000..9ee8898
--- /dev/null
+++ b/test/modules/http2/test_700_load_get.py
@@ -0,0 +1,63 @@
+import pytest
+
+from .env import H2Conf, H2TestEnv
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+@pytest.mark.skipif(not H2TestEnv().h2load_is_at_least('1.41.0'),
+ reason="h2load misses --connect-to option")
+class TestLoadGet:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ H2Conf(env).add_vhost_cgi().add_vhost_test1().install()
+ assert env.apache_restart() == 0
+
+ def check_h2load_ok(self, env, r, n):
+ assert 0 == r.exit_code
+ r = env.h2load_status(r)
+ assert n == r.results["h2load"]["requests"]["total"]
+ assert n == r.results["h2load"]["requests"]["started"]
+ assert n == r.results["h2load"]["requests"]["done"]
+ assert n == r.results["h2load"]["requests"]["succeeded"]
+ assert n == r.results["h2load"]["status"]["2xx"]
+ assert 0 == r.results["h2load"]["status"]["3xx"]
+ assert 0 == r.results["h2load"]["status"]["4xx"]
+ assert 0 == r.results["h2load"]["status"]["5xx"]
+
+ # test load on cgi script, single connection, different sizes
+ @pytest.mark.parametrize("start", [
+ 1000, 80000
+ ])
+ def test_h2_700_10(self, env, start):
+ assert env.is_live()
+ text = "X"
+ chunk = 32
+ for n in range(0, 5):
+ args = [env.h2load, "-n", "%d" % chunk, "-c", "1", "-m", "10",
+ f"--connect-to=localhost:{env.https_port}",
+ f"--base-uri={env.mkurl('https', 'cgi', '/')}",
+ ]
+ for i in range(0, chunk):
+ args.append(env.mkurl("https", "cgi", ("/mnot164.py?count=%d&text=%s" % (start+(n*chunk)+i, text))))
+ r = env.run(args)
+ self.check_h2load_ok(env, r, chunk)
+
+ # test load on cgi script, single connection
+ @pytest.mark.parametrize("conns", [
+ 1, 2, 16, 32
+ ])
+ def test_h2_700_11(self, env, conns):
+ assert env.is_live()
+ text = "X"
+ start = 1200
+ chunk = 64
+ for n in range(0, 5):
+ args = [env.h2load, "-n", "%d" % chunk, "-c", "%d" % conns, "-m", "10",
+ f"--connect-to=localhost:{env.https_port}",
+ f"--base-uri={env.mkurl('https', 'cgi', '/')}",
+ ]
+ for i in range(0, chunk):
+ args.append(env.mkurl("https", "cgi", ("/mnot164.py?count=%d&text=%s" % (start+(n*chunk)+i, text))))
+ r = env.run(args)
+ self.check_h2load_ok(env, r, chunk)
diff --git a/test/modules/http2/test_710_load_post_static.py b/test/modules/http2/test_710_load_post_static.py
new file mode 100644
index 0000000..ad8ae96
--- /dev/null
+++ b/test/modules/http2/test_710_load_post_static.py
@@ -0,0 +1,65 @@
+import pytest
+import os
+
+from .env import H2Conf, H2TestEnv
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestLoadPostStatic:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ H2Conf(env).add_vhost_test1().install()
+ assert env.apache_restart() == 0
+
+ def check_h2load_ok(self, env, r, n):
+ assert 0 == r.exit_code
+ r = env.h2load_status(r)
+ assert n == r.results["h2load"]["requests"]["total"]
+ assert n == r.results["h2load"]["requests"]["started"]
+ assert n == r.results["h2load"]["requests"]["done"]
+ assert n == r.results["h2load"]["requests"]["succeeded"]
+ assert n == r.results["h2load"]["status"]["2xx"]
+ assert 0 == r.results["h2load"]["status"]["3xx"]
+ assert 0 == r.results["h2load"]["status"]["4xx"]
+ assert 0 == r.results["h2load"]["status"]["5xx"]
+
+ # test POST on static file, slurped in by server
+ def test_h2_710_00(self, env, repeat):
+ assert env.is_live()
+ url = env.mkurl("https", "test1", "/index.html")
+ n = 10
+ m = 1
+ conn = 1
+ fname = "data-10k"
+ args = [env.h2load, "-n", f"{n}", "-c", f"{conn}", "-m", f"{m}",
+ f"--base-uri={env.https_base_url}",
+ "-d", os.path.join(env.gen_dir, fname), url]
+ r = env.run(args)
+ self.check_h2load_ok(env, r, n)
+
+ def test_h2_710_01(self, env, repeat):
+ assert env.is_live()
+ url = env.mkurl("https", "test1", "/index.html")
+ n = 1000
+ m = 100
+ conn = 1
+ fname = "data-1k"
+ args = [env.h2load, "-n", f"{n}", "-c", f"{conn}", "-m", f"{m}",
+ f"--base-uri={env.https_base_url}",
+ "-d", os.path.join(env.gen_dir, fname), url]
+ r = env.run(args)
+ self.check_h2load_ok(env, r, n)
+
+ def test_h2_710_02(self, env, repeat):
+ assert env.is_live()
+ url = env.mkurl("https", "test1", "/index.html")
+ n = 100
+ m = 50
+ conn = 1
+ fname = "data-100k"
+ args = [env.h2load, "-n", f"{n}", "-c", f"{conn}", "-m", f"{m}",
+ f"--base-uri={env.https_base_url}",
+ "-d", os.path.join(env.gen_dir, fname), url]
+ r = env.run(args)
+ self.check_h2load_ok(env, r, n)
diff --git a/test/modules/http2/test_711_load_post_cgi.py b/test/modules/http2/test_711_load_post_cgi.py
new file mode 100644
index 0000000..82529d1
--- /dev/null
+++ b/test/modules/http2/test_711_load_post_cgi.py
@@ -0,0 +1,73 @@
+import pytest
+import os
+
+from .env import H2Conf, H2TestEnv
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestLoadCgi:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ H2Conf(env).add_vhost_cgi(proxy_self=True, h2proxy_self=True).install()
+ assert env.apache_restart() == 0
+
+ def check_h2load_ok(self, env, r, n):
+ assert 0 == r.exit_code
+ r = env.h2load_status(r)
+ assert n == r.results["h2load"]["requests"]["total"]
+ assert n == r.results["h2load"]["requests"]["started"]
+ assert n == r.results["h2load"]["requests"]["done"]
+ assert n == r.results["h2load"]["requests"]["succeeded"]
+ assert n == r.results["h2load"]["status"]["2xx"]
+ assert 0 == r.results["h2load"]["status"]["3xx"]
+ assert 0 == r.results["h2load"]["status"]["4xx"]
+ assert 0 == r.results["h2load"]["status"]["5xx"]
+
+ # test POST on cgi, where input is read
+ def test_h2_711_10(self, env, repeat):
+ assert env.is_live()
+ url = env.mkurl("https", "test1", "/echo.py")
+ n = 100
+ m = 5
+ conn = 1
+ fname = "data-100k"
+ args = [
+ env.h2load, "-n", str(n), "-c", str(conn), "-m", str(m),
+ f"--base-uri={env.https_base_url}",
+ "-d", os.path.join(env.gen_dir, fname), url
+ ]
+ r = env.run(args)
+ self.check_h2load_ok(env, r, n)
+
+ # test POST on cgi via http/1.1 proxy, where input is read
+ def test_h2_711_11(self, env, repeat):
+ assert env.is_live()
+ url = env.mkurl("https", "test1", "/proxy/echo.py")
+ n = 100
+ m = 5
+ conn = 1
+ fname = "data-100k"
+ args = [
+ env.h2load, "-n", str(n), "-c", str(conn), "-m", str(m),
+ f"--base-uri={env.https_base_url}",
+ "-d", os.path.join(env.gen_dir, fname), url
+ ]
+ r = env.run(args)
+ self.check_h2load_ok(env, r, n)
+
+ # test POST on cgi via h2proxy, where input is read
+ def test_h2_711_12(self, env, repeat):
+ assert env.is_live()
+ url = env.mkurl("https", "test1", "/h2proxy/echo.py")
+ n = 100
+ m = 5
+ conn = 1
+ fname = "data-100k"
+ args = [
+ env.h2load, "-n", str(n), "-c", str(conn), "-m", str(m),
+ f"--base-uri={env.https_base_url}",
+ "-d", os.path.join(env.gen_dir, fname), url
+ ]
+ r = env.run(args)
+ self.check_h2load_ok(env, r, n)
diff --git a/test/modules/http2/test_712_buffering.py b/test/modules/http2/test_712_buffering.py
new file mode 100644
index 0000000..9eb2689
--- /dev/null
+++ b/test/modules/http2/test_712_buffering.py
@@ -0,0 +1,58 @@
+from datetime import timedelta
+
+import pytest
+
+from .env import H2Conf, H2TestEnv
+from pyhttpd.curl import CurlPiper
+
+
+@pytest.mark.skipif(condition=H2TestEnv.is_unsupported, reason="mod_http2 not supported here")
+class TestBuffering:
+
+ @pytest.fixture(autouse=True, scope='class')
+ def _class_scope(self, env):
+ conf = H2Conf(env)
+ conf.add_vhost_cgi(h2proxy_self=True).install()
+ assert env.apache_restart() == 0
+
+ @pytest.mark.skip(reason="this test shows unreliable jitter")
+ def test_h2_712_01(self, env):
+ # test gRPC like requests that do not end, but give answers, see #207
+ #
+ # this test works like this:
+ # - use curl to POST data to the server /h2test/echo
+ # - feed curl the data in chunks, wait a bit between chunks
+ # - since some buffering on curl's stdout to Python is involved,
+ # we will see the response data only at the end.
+ # - therefore, we enable tracing with timestamps in curl on stderr
+ # and see when the response chunks arrive
+ # - if the server sends the incoming data chunks back right away,
+ # as it should, we see receiving timestamps separated roughly by the
+ # wait time between sends.
+ #
+ url = env.mkurl("https", "cgi", "/h2test/echo")
+ base_chunk = "0123456789"
+ chunks = ["chunk-{0:03d}-{1}\n".format(i, base_chunk) for i in range(5)]
+ stutter = timedelta(seconds=0.2) # this is short, but works on my machine (tm)
+ piper = CurlPiper(env=env, url=url)
+ piper.stutter_check(chunks, stutter)
+
+ def test_h2_712_02(self, env):
+ # same as 712_01 but via mod_proxy_http2
+ #
+ url = env.mkurl("https", "cgi", "/h2proxy/h2test/echo")
+ base_chunk = "0123456789"
+ chunks = ["chunk-{0:03d}-{1}\n".format(i, base_chunk) for i in range(3)]
+ stutter = timedelta(seconds=0.4) # need a bit more delay since we have the extra connection
+ piper = CurlPiper(env=env, url=url)
+ piper.stutter_check(chunks, stutter)
+
+ def test_h2_712_03(self, env):
+ # same as 712_02 but with smaller chunks
+ #
+ url = env.mkurl("https", "cgi", "/h2proxy/h2test/echo")
+ base_chunk = "0"
+ chunks = ["ck{0}-{1}\n".format(i, base_chunk) for i in range(3)]
+ stutter = timedelta(seconds=0.4) # need a bit more delay since we have the extra connection
+ piper = CurlPiper(env=env, url=url)
+ piper.stutter_check(chunks, stutter)