NGINXApiModule

Common NGINX code that will be appended into each NGINX based integration when it's deployed.

python · ApiModules

Details

IDNGINXApiModule
Languagepython
From Version5.5.0
Docker Imagedemisto/flask-nginx:1.0.0.20328
Tagsinfra server

README

Common NGINX code that will be appended into each NGINX based integration when it’s deployed

Script Data


Name Description
Script Type python3
Tags infra, server
Cortex XSOAR Version 5.5.0

Inputs


There are no inputs for this script.

Outputs


There are no outputs for this script.

import os
import subprocess
from pathlib import Path
from time import sleep

import demistomock as demisto
import pytest
import requests
from CommonServerPython import DemistoException
from pytest_mock import MockerFixture

SSL_TEST_KEY = """-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDd5FcvCKgtXjkY
aiDdqpFAYKw6WxNEpZIGjzD9KhEqr7OZjpPoLeyGh1U6faAcN6XpkQugFA/2Gq+Z
j/pe1abiTCbctdE978FYVjXxbEEAtEn4x28s/bKah/xjjw+RjUyQB9DsioFkV1eN
9iJh5eOOIOjTDMBt7SxY1HivC0HjUKjCaMjdH2WxGu4na9phPOa7zixlgLqZGC8g
E1Ati5j3nOEOlmrNIf1Z/4zdJzEaMprBCymfEvrgMC7ibG9AokDcAj6Sl4xgvTRp
tTczCbUxF1jsnNNbuLyq/RuQ85SWB3mrRKT4OgPtz/ga3sm4l7Uq/YN71Gr/Lxaq
bkwWVMd/AgMBAAECggEBAKnMfacBYejtzJVRSXs3dlWkZMd3QGRsqzUXyG5DDcXz
lGVyxN6Mng5Ia8EJt0dAklcM5q+GCrzSqQPDON3vcviDO83z2H4kBXm65yarJ4cJ
b/3PZ9UvAsjcPRhWtpw0W51wTcFlMCT/7YE2FBOEX0E5D9HJVUwJjcEgPoX9AFuY
xYVpFvr1AoORde/RoJGoe+Z81hIRvcbrzfLHEMCB0pY0wxBuD5tyhEunIwLxG+6v
T1OHtuXDATEGabZQJKuhBfuP00YFRKxHIBLWPtkplQGFAXmBEeD5NIYfo+RBQFUH
GuvDTHoEvecn9ZHF4eOjJ88TXaGuXrFHwa0g0KMDNaECgYEA+ld2bkC4RXNWIzYI
4bOH7UBFrd74nz4zqNd2UZqP9R1al8nLgcESiT6izBbR+6wnNANIon5fXYGFK+wu
NGvKwuL1Xf04Ro/Z/i03QrV5fTgL/F8NX9F0kc6znxli9SrpswSjb1ZUoJmQXCew
ZYkCVavy3Zpgk8uHeeaHOOAI6k8CgYEA4uhC2Jy9Ysq6Eg79hVq0xHtXLl0VWfrU
5mugItrH90LmsCvKK4Qzg33BjhIMbE9vq63yFxW08845weuxUV6LalPSLOclE7D2
6exG5grcdGpqyWKc2qCAXP2uLys68cOfWduJoVUYsdAGbyNdvkI69VcTsI8pV6kR
bjzP+l50c9ECgYA3CVN4GbJpUln1k8OQGzAe8Kpg90whdkNVM0lH13seoD1ycWLU
O+YfVi3kQIAZnFdiD/bAAphkrjzg0yO1Up1ZCxx2dV0R5j4+qyIjAFKdPN0ltp/y
GNJP2+mRaLtguvZ17OchaxFf3WLnX7JgICbrPso9/dqNo4k9O3ku/9H18QKBgQDZ
LaMlfsgJ8a2ssSpYZBwW31LvbmqMR/dUX/jSw4KXmDICtrb3db50gX4rw/yeAl4I
/SF0lPMwU9eWU0fRcOORro7BKa+kLEH4XYzyi7y7tEtnW3p0CyExYCFCxmbRlgJE
WEtf3noXXtt5rmkAPJX/0wtmd3ADli+3yn7pzVQ6sQKBgQDJJITERtov019Cwuux
fCRUIbRyUH/PCN/VvsuKFs+BWbFTnqBXRDQetzTyuUvNKiL7GmWQuR/QpgYjLd9W
jxAayhtcVKeL96dqimK9twmw/NC5DveOVoReXx7io4gicmQi7AGq5WRkm8NUZRVE
1dH1Hhp7kjnPlUOUBvKf8mfFxQ==
-----END PRIVATE KEY-----
"""

SSL_TEST_CRT = """-----BEGIN CERTIFICATE-----
MIIDeTCCAmGgAwIBAgIUaam3vV40bjLs7mabludFi6dRsxkwDQYJKoZIhvcNAQEL
BQAwTDELMAkGA1UEBhMCSUwxEzARBgNVBAgMClNvbWUtU3RhdGUxEzARBgNVBAoM
ClhTT0FSIFRlc3QxEzARBgNVBAMMCnhzb2FyLXRlc3QwHhcNMjEwNTE2MTQzNDU0
WhcNMzAwODAyMTQzNDU0WjBMMQswCQYDVQQGEwJJTDETMBEGA1UECAwKU29tZS1T
dGF0ZTETMBEGA1UECgwKWFNPQVIgVGVzdDETMBEGA1UEAwwKeHNvYXItdGVzdDCC
ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAN3kVy8IqC1eORhqIN2qkUBg
rDpbE0SlkgaPMP0qESqvs5mOk+gt7IaHVTp9oBw3pemRC6AUD/Yar5mP+l7VpuJM
Jty10T3vwVhWNfFsQQC0SfjHbyz9spqH/GOPD5GNTJAH0OyKgWRXV432ImHl444g
6NMMwG3tLFjUeK8LQeNQqMJoyN0fZbEa7idr2mE85rvOLGWAupkYLyATUC2LmPec
4Q6Was0h/Vn/jN0nMRoymsELKZ8S+uAwLuJsb0CiQNwCPpKXjGC9NGm1NzMJtTEX
WOyc01u4vKr9G5DzlJYHeatEpPg6A+3P+BreybiXtSr9g3vUav8vFqpuTBZUx38C
AwEAAaNTMFEwHQYDVR0OBBYEFJLT/bq2cGAu6buAQSoeusx439YaMB8GA1UdIwQY
MBaAFJLT/bq2cGAu6buAQSoeusx439YaMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI
hvcNAQELBQADggEBACmcsfDI382F64TWtJaEn4pKCTjiJloXfb3curr7qYVfeLUX
jbb6aRha88/PB+6/IC/lR0JjXRWMMQafFFR7rb1290p2YVPE9T5Wc5934M590LxZ
bwa5YsCF+qzBiWPMUs5s/el8AHTnUQdU/CKLMI7ZL2IpyTpfW4PERw2HiOBdgCbl
1DzjH9L1bmCzIhXBR6bUXUn4vjg8VBIQ29uHrLNN1fgDyRB1eAaOs4iuBAZm7IkC
k+cVw239GwbLsYkRg5BpkQF4IC6a4+Iz9fpvpUc/g6jpxtGU0kE2DVWOEAyPOOWC
C/t/GFcoOUze68WuI/BqMAiWhPJ1ioL7RI2ZPvI=
-----END CERTIFICATE-----
"""


def test_nginx_conf(tmp_path: Path, mocker):
    from NGINXApiModule import create_nginx_server_conf

    conf_file = str(tmp_path / "nginx-test-server.conf")
    mocker.patch.object(demisto, "callingContext", return_value={"context": {}})
    create_nginx_server_conf(conf_file, 12345, params={})
    with open(conf_file) as f:
        conf = f.read()
        assert "listen 12345 default_server" in conf


def test_nginx_conf_taxii2(tmp_path: Path, mocker):
    from NGINXApiModule import create_nginx_server_conf

    mocker.patch.object(demisto, "callingContext", {"context": {"IntegrationBrand": "TAXII2 Server"}})
    conf_file = str(tmp_path / "nginx-test-server.conf")
    create_nginx_server_conf(conf_file, 12345, params={"version": "2.0", "credentials": {"identifier": "identifier"}})
    with open(conf_file) as f:
        conf = f.read()
        assert "$http_authorization" in conf
        assert "$http_accept" in conf
        assert "proxy_set_header Range $http_range;" in conf
        assert "$http_range" in conf


NGINX_PROCESS: subprocess.Popen | None = None


def _create_test_nginx_main_conf(tmp_path: Path):
    """Create a custom nginx main config that includes our test conf directory.

    Uses tmp_path for all writable resources so tests can run as non-root and ensure isolation.
    """
    nginx_test_conf_dir = tmp_path / "nginx-test-conf.d"
    nginx_test_cache_dir = tmp_path / "nginx-test-cache"
    nginx_test_tmp_dir = tmp_path / "nginx-test-tmp"
    nginx_test_pid_file = tmp_path / "nginx-test.pid"
    nginx_test_error_log = tmp_path / "nginx-test-error.log"
    nginx_test_access_log = tmp_path / "nginx-test-access.log"
    nginx_test_main_conf = tmp_path / "nginx-test-main.conf"

    nginx_test_conf_dir.mkdir(parents=True, exist_ok=True)
    nginx_test_cache_dir.mkdir(parents=True, exist_ok=True)
    nginx_test_tmp_dir.mkdir(parents=True, exist_ok=True)

    main_conf = f"""
pid {nginx_test_pid_file};
pcre_jit on;
error_log {nginx_test_error_log} crit;
include /etc/nginx/modules/*.conf;
events {{
    worker_connections 1024;
}}
http {{
    include /etc/nginx/mime.types;
    root /var/lib/nginx/html;
    server_tokens off;
    client_max_body_size 1m;
    keepalive_timeout 65;
    sendfile on;
    tcp_nodelay on;
    ssl_prefer_server_ciphers on;
    ssl_session_cache shared:SSL:2m;
    gzip on;
    gzip_vary on;
    gzip_min_length 512;
    gzip_types text/javascript text/xml text/plain application/javascript application/x-javascript application/json;
    gzip_proxied any;
    proxy_http_version 1.1;
    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
        '$status $body_bytes_sent "$http_referer" '
        '"$http_user_agent" "$http_x_forwarded_for"';
    access_log {nginx_test_access_log} main;
    client_body_temp_path {nginx_test_tmp_dir}/client_body;
    proxy_temp_path {nginx_test_tmp_dir}/proxy;
    fastcgi_temp_path {nginx_test_tmp_dir}/fastcgi;
    uwsgi_temp_path {nginx_test_tmp_dir}/uwsgi;
    scgi_temp_path {nginx_test_tmp_dir}/scgi;
    proxy_cache_path {nginx_test_cache_dir} levels=1:2 keys_zone=mycache:5m max_size=2g inactive=60m use_temp_path=off;
    proxy_cache mycache;
    proxy_cache_valid 5m;
    proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504 http_429;
    proxy_cache_background_update on;
    proxy_cache_lock on;
    proxy_cache_lock_age 60s;
    proxy_cache_lock_timeout 60s;
    proxy_cache_revalidate on;
    proxy_read_timeout 300s;
    include {nginx_test_conf_dir}/*.conf;
}}
"""
    with open(nginx_test_main_conf, "w") as f:
        f.write(main_conf)
    return {
        "main_conf": str(nginx_test_main_conf),
        "conf_dir": str(nginx_test_conf_dir),
        "access_log": str(nginx_test_access_log),
        "error_log": str(nginx_test_error_log),
    }


@pytest.fixture
def nginx_cleanup(monkeypatch, tmp_path):
    import NGINXApiModule as module

    # Create writable directories for nginx config and SSL files
    nginx_test_ssl_dir = tmp_path / "nginx-test-ssl"
    nginx_test_ssl_dir.mkdir(parents=True, exist_ok=True)

    # Create custom nginx main config pointing to our test conf directory
    paths = _create_test_nginx_main_conf(tmp_path)
    nginx_test_main_conf = paths["main_conf"]
    nginx_test_conf_dir = paths["conf_dir"]
    nginx_test_access_log = paths["access_log"]
    nginx_test_error_log = paths["error_log"]

    test_conf_file = f"{nginx_test_conf_dir}/default.conf"
    test_ssl_crt = str(nginx_test_ssl_dir / "ssl.crt")
    test_ssl_key = str(nginx_test_ssl_dir / "ssl.key")

    # Patch module constants to use writable paths
    monkeypatch.setattr(module, "NGINX_SERVER_CONF_FILE", test_conf_file)
    monkeypatch.setattr(module, "NGINX_SSL_CRT_FILE", test_ssl_crt)
    monkeypatch.setattr(module, "NGINX_SSL_KEY_FILE", test_ssl_key)
    monkeypatch.setattr(
        module,
        "NGINX_SSL_CERTS",
        f"\n    ssl_certificate {test_ssl_crt};\n    ssl_certificate_key {test_ssl_key};\n",
    )
    monkeypatch.setattr(module, "NGINX_SERVER_ACCESS_LOG", nginx_test_access_log)
    monkeypatch.setattr(module, "NGINX_SERVER_ERROR_LOG", nginx_test_error_log)

    # Wrap start_nginx_server to use our custom main config via -c flag
    def _patched_start_nginx_server(port: int, params: dict | None = None) -> subprocess.Popen:
        params = params if params is not None else demisto.params()
        module.create_nginx_server_conf(module.NGINX_SERVER_CONF_FILE, port, params)
        nginx_global_directives = "daemon off;"
        global_directives_conf = params.get("nginx_global_directives")
        if global_directives_conf:
            nginx_global_directives = f"{nginx_global_directives} {global_directives_conf}"
        directive_args = ["-g", nginx_global_directives]
        try:
            nginx_test_command = ["nginx", "-c", nginx_test_main_conf, "-T"]
            nginx_test_command.extend(directive_args)
            test_output = subprocess.check_output(nginx_test_command, stderr=subprocess.STDOUT, text=True)
            demisto.info(f"ngnix test passed. command: [{nginx_test_command}]")
            demisto.debug(f"nginx test ouput:\n{test_output}")
        except subprocess.CalledProcessError as err:
            raise ValueError(f"Failed testing nginx conf. Return code: {err.returncode}. Output: {err.output}")
        nginx_command = ["nginx", "-c", nginx_test_main_conf]
        nginx_command.extend(directive_args)
        res = subprocess.Popen(nginx_command, text=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        demisto.info(f"done starting nginx with pid: {res.pid}")
        return res

    monkeypatch.setattr(module, "start_nginx_server", _patched_start_nginx_server)

    yield paths
    global NGINX_PROCESS
    if NGINX_PROCESS:
        NGINX_PROCESS.terminate()
        # let the process terminate
        NGINX_PROCESS.wait(1.0)
        NGINX_PROCESS = None


docker_only = pytest.mark.skipif("flask-nginx" not in os.getenv("DOCKER_IMAGE", ""), reason="test should run only within docker")


@docker_only
def test_nginx_start_fail(mocker: MockerFixture, nginx_cleanup):
    """Test that nginx fails when config is invalid"""
    import NGINXApiModule as module

    nginx_test_main_conf = nginx_cleanup["main_conf"]

    def nginx_bad_conf(file_path: str, port: int, params: dict):
        with open(file_path, "w") as f:
            f.write("server {bad_stuff test;}")

    # Override create_nginx_server_conf to write bad config, but keep the patched start_nginx_server
    def _start_with_bad_conf(port: int, params: dict | None = None) -> subprocess.Popen:
        params = params if params is not None else {}
        nginx_bad_conf(module.NGINX_SERVER_CONF_FILE, port, params)
        nginx_global_directives = "daemon off;"
        directive_args = ["-g", nginx_global_directives]
        try:
            nginx_test_command = ["nginx", "-c", nginx_test_main_conf, "-T"]
            nginx_test_command.extend(directive_args)
            subprocess.check_output(nginx_test_command, stderr=subprocess.STDOUT, text=True)
        except subprocess.CalledProcessError as err:
            raise ValueError(f"Failed testing nginx conf. Return code: {err.returncode}. Output: {err.output}")
        nginx_command = ["nginx", "-c", nginx_test_main_conf]
        nginx_command.extend(directive_args)
        return subprocess.Popen(nginx_command, text=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

    mocker.patch.object(module, "start_nginx_server", side_effect=_start_with_bad_conf)
    with pytest.raises(ValueError) as e:
        module.start_nginx_server(12345, {})
        assert "bad_stuff" in str(e)


@docker_only
def test_nginx_start_fail_directive(nginx_cleanup, mocker):
    """Test that nginx fails when invalid global directive is passed"""
    import NGINXApiModule as module

    with pytest.raises(ValueError) as e:
        mocker.patch.object(demisto, "callingContext", return_value={"context": {}})
        module.start_nginx_server(12345, {"nginx_global_directives": "bad_directive test;"})
        assert "bad_directive" in str(e)


@docker_only
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
@pytest.mark.parametrize(
    "params",
    [
        {},
        {"certificate": SSL_TEST_CRT, "key": SSL_TEST_KEY},
    ],
)
def test_nginx_test_start_valid(nginx_cleanup, params, mocker):
    import NGINXApiModule as module

    mocker.patch.object(demisto, "callingContext", return_value={"context": {}})
    module.test_nginx_server(11300, params)
    # check that nginx process is not up
    sleep(0.5)
    ps_out = subprocess.check_output(["ps", "aux"], text=True)
    assert "nginx" not in ps_out


@docker_only
def test_nginx_log_process(nginx_cleanup, mocker: MockerFixture):
    import NGINXApiModule as module

    # clear logs for test
    Path(module.NGINX_SERVER_ACCESS_LOG).unlink(missing_ok=True)
    Path(module.NGINX_SERVER_ERROR_LOG).unlink(missing_ok=True)
    global NGINX_PROCESS
    mocker.patch.object(demisto, "callingContext", return_value={"context": {}})
    NGINX_PROCESS = module.start_nginx_server(12345, {})
    sleep(0.5)  # give nginx time to start
    # create a request to get a log line
    requests.get("http://localhost:12345/nginx-test?unit_testing")
    # Poll until nginx has flushed the access-log line to the (test-redirected)
    # log before invoking nginx_log_process. A fixed short sleep is racy: if the
    # line isn't on disk yet, nginx_log_process sees an empty log, logs nothing,
    # and demisto.debug.call_args is None.
    for _ in range(50):
        if Path(module.NGINX_SERVER_ACCESS_LOG).exists() and Path(module.NGINX_SERVER_ACCESS_LOG).stat().st_size:
            break
        sleep(0.1)
    mocker.patch.object(demisto, "debug")
    mocker.patch.object(demisto, "error")
    module.nginx_log_process(NGINX_PROCESS)
    # The nginx access log line is emitted via demisto.debug.
    # call_args is tuple (args list, kwargs). we only need the args
    arg = demisto.debug.call_args[0][0]
    assert "nginx access log" in arg
    assert "unit_testing" in arg
    # make sure old file was removed
    assert not Path(module.NGINX_SERVER_ACCESS_LOG + ".old").exists()
    assert not Path(module.NGINX_SERVER_ERROR_LOG + ".old").exists()
    # make sure access log was rolled over (should be of size 0). The error log
    # is not asserted as empty because nginx may emit alert/emerg messages
    # (e.g. when reopening files as a non-root worker) that bypass the `crit`
    # filter and are written to the new file immediately after rollover.
    assert not Path(module.NGINX_SERVER_ACCESS_LOG).stat().st_size


def test_nginx_web_server_is_down(requests_mock, capfd):
    import NGINXApiModule as module

    with capfd.disabled():
        requests_mock.get("http://localhost:9009/nginx-test", status_code=404)
        with pytest.raises(
            DemistoException, match="Testing nginx server: 404 Client Error: None for url: http://localhost:9009/nginx-test"
        ):
            module.test_nginx_web_server(9009, {})


def test_nginx_web_server_is_up_running(requests_mock):
    import NGINXApiModule as module

    requests_mock.get("http://localhost:9009/nginx-test", status_code=200, text="Welcome to nginx")
    try:
        module.test_nginx_web_server(9009, {})
    except DemistoException as ex:
        pytest.fail(f"Failed to test nginx server. {ex}")


def test_lost_connection_engine_to_server(mocker):
    import NGINXApiModule as module
    from flask import Flask

    module.APP = Flask("demisto-edl")

    mocker.patch.object(demisto, "info", side_effect=ValueError("Try to write when connection closed"))
    mocker.patch.object(demisto, "error", side_effect=ValueError("Try to write when connection closed"))
    mocker.patch.object(demisto, "params", return_value={"longRunningPort": "8080"})
    mocker.patch.object(module, "start_nginx_server", side_effect=ValueError("Try to write when connection closed"))
    with pytest.raises(SystemExit) as e:
        module.run_long_running()
        assert e.value.code == 1


@pytest.mark.parametrize(
    "time_str, expected_seconds",
    [
        ("3600", 3600),
        ("1s", 1),
        ("30m", 1800),
        ("1h", 3600),
        ("1d", 86400),
        ("1w", 604800),
        ("1M", 2592000),
        ("1y", 31536000),
    ],
)
def test_parse_nginx_time_to_seconds(time_str, expected_seconds):
    from NGINXApiModule import parse_nginx_time_to_seconds

    assert parse_nginx_time_to_seconds(time_str) == expected_seconds


@pytest.mark.parametrize(
    "time_str, expected_seconds",
    [
        ("5 minutes", 300),
        ("30 minutes", 1800),
        ("1 hour", 3600),
        ("2 hours", 7200),
        ("1 day", 86400),
        ("3 days", 259200),
    ],
)
def test_parse_nginx_time_to_seconds_human_readable(time_str, expected_seconds):
    """
    Given:  a human-readable relative time string used by integrations such as EDL
            (e.g. "5 minutes", "1 hour", "3 days").
    When:   parse_nginx_time_to_seconds is called with that string.
    Then:   the value is converted to the corresponding number of seconds
            (allowing a small tolerance because dateparser computes against "now").
    """
    from NGINXApiModule import parse_nginx_time_to_seconds

    result = parse_nginx_time_to_seconds(time_str)
    # Allow ~2s tolerance for the live now() computation done inside the function.
    assert abs(result - expected_seconds) <= 2


@pytest.mark.parametrize(
    "time_str",
    [
        "",
        "   ",
        "invalid",
        None,
    ],
)
def test_parse_nginx_time_to_seconds_fail(time_str):
    from NGINXApiModule import parse_nginx_time_to_seconds

    with pytest.raises(DemistoException, match="Invalid NGINX time format"):
        parse_nginx_time_to_seconds(time_str)


def test_normalize_nginx_time_integer_string():
    """
    Given:  the pure-integer string "300".
    When:   _normalize_nginx_time is called with it.
    Then:   it returns the canonical "<int>s" form ("300s").
    """
    from NGINXApiModule import _normalize_nginx_time

    assert _normalize_nginx_time("300", default="1m", param_name="cache_404_ttl") == "300s"


def test_normalize_nginx_time_integer_int():
    """
    Given:  an actual Python int (300).
    When:   _normalize_nginx_time is called with it.
    Then:   it is coerced through str() and returned as "300s".
    """
    from NGINXApiModule import _normalize_nginx_time

    assert _normalize_nginx_time(300, default="1m", param_name="cache_404_ttl") == "300s"


@pytest.mark.parametrize(
    "value, expected",
    [
        ("12h", "43200s"),
        ("30m", "1800s"),
        ("1d", "86400s"),
        ("2w", "1209600s"),
        ("1M", "2592000s"),
        ("1y", "31536000s"),
        ("60s", "60s"),
    ],
)
def test_normalize_nginx_time_nginx_native_single_token(value, expected):
    """
    Given:  an nginx-native single-token time string (e.g. "12h", "30m", "1M").
    When:   _normalize_nginx_time is called with it.
    Then:   it is converted to the equivalent "<seconds>s" form.
    """
    from NGINXApiModule import _normalize_nginx_time

    assert _normalize_nginx_time(value, default="1m", param_name="cache_refresh_rate") == expected


@pytest.mark.parametrize(
    "value, expected_seconds",
    [
        ("12 hours", 43200),
        ("30 minutes", 1800),
        ("2 days", 172800),
        ("2 weeks", 1209600),
    ],
)
def test_normalize_nginx_time_human_readable_plural(value, expected_seconds):
    """
    Given:  a plural human-readable time string (e.g. "12 hours", "30 minutes").
    When:   _normalize_nginx_time is called with it.
    Then:   the returned "<int>s" token equals the expected number of seconds
            (allowing ±2s tolerance for the live now() computation inside
            parse_nginx_time_to_seconds — mirrors the pattern at L407-L408).
    """
    from NGINXApiModule import _normalize_nginx_time

    result = _normalize_nginx_time(value, default="1m", param_name="cache_refresh_rate")
    assert result.endswith("s")
    assert abs(int(result[:-1]) - expected_seconds) <= 2


@pytest.mark.parametrize(
    "value, expected_seconds",
    [
        ("1 hour", 3600),
        ("1 minute", 60),
        ("1 day", 86400),
    ],
)
def test_normalize_nginx_time_human_readable_singular(value, expected_seconds):
    """
    Given:  a singular human-readable time string (e.g. "1 hour", "1 minute").
    When:   _normalize_nginx_time is called with it.
    Then:   the returned "<int>s" token equals the expected number of seconds
            (±2s tolerance, same rationale as the plural case).
    """
    from NGINXApiModule import _normalize_nginx_time

    result = _normalize_nginx_time(value, default="1m", param_name="cache_refresh_rate")
    assert result.endswith("s")
    assert abs(int(result[:-1]) - expected_seconds) <= 2


@pytest.mark.parametrize(
    "value, default, expected",
    [
        (None, "1m", "60s"),
        ("", "300", "300s"),
        ("   ", "12h", "43200s"),
    ],
)
def test_normalize_nginx_time_empty_uses_default(value, default, expected):
    """
    Given:  an empty / whitespace-only / None value plus a non-empty default.
    When:   _normalize_nginx_time is called.
    Then:   the default is normalized and returned in "<int>s" form.
    """
    from NGINXApiModule import _normalize_nginx_time

    assert _normalize_nginx_time(value, default=default, param_name="cache_404_ttl") == expected


@pytest.mark.parametrize(
    "value",
    [
        "hours",
        "12 hours and 5 minutes",
        "abc",
        "-5",
    ],
)
def test_normalize_nginx_time_invalid_raises(value):
    """
    Given:  a non-empty but unparseable / non-positive time value.
    When:   _normalize_nginx_time is called.
    Then:   a DemistoException is raised whose message names the offending
            parameter so users can pinpoint the misconfigured field.
    """
    from NGINXApiModule import _normalize_nginx_time

    with pytest.raises(DemistoException, match="cache_refresh_rate"):
        _normalize_nginx_time(value, default="1m", param_name="cache_refresh_rate")


def test_create_nginx_server_conf_renders_seconds_for_all_five_params(tmp_path: Path, mocker):
    """
    Given:  a params dict that mixes nginx-native, human-readable and bare-integer
            forms across the cache_* time params (plus a "timeout" of 3600s that
            should floor-bump cache_refresh_rate).
    When:   create_nginx_server_conf renders the nginx server config.
    Then:   every rendered cache-time directive is in the safe "<int>s" form, and no
            occurrence of any human-readable unit word or original input token
            survives into the rendered conf.

    Note:   the two-tier design NO LONGER emits proxy_cache_lock_timeout /
            proxy_cache_lock_age (concurrency is controlled by Tier-2 limit_conn,
            not by a cache lock), so those directives must be ABSENT.
    """
    from NGINXApiModule import create_nginx_server_conf

    mocker.patch.object(demisto, "callingContext", return_value={"context": {}})
    conf_file = str(tmp_path / "nginx-test-server.conf")
    create_nginx_server_conf(
        conf_file,
        12345,
        params={
            "timeout": "3600",
            "cache_refresh_rate": "12 hours",
            "cache_404_ttl": "5 minutes",
            "cache_default_ttl": "300",
        },
    )
    with open(conf_file) as f:
        conf = f.read()

    # cache_refresh_rate = "12 hours" -> 43200s (>= timeout, not floor-bumped)
    assert "proxy_cache_valid 200 301 302 43200s;" in conf
    # cache_404_ttl = "5 minutes" -> 300s
    assert "proxy_cache_valid 404 300s;" in conf
    # cache_default_ttl = "300" -> 300s
    assert "proxy_cache_valid any 300s;" in conf

    # The cache-lock tuning directives are no longer part of the two-tier design.
    assert "proxy_cache_lock_timeout" not in conf
    assert "proxy_cache_lock_age" not in conf

    # Hardening: none of the original unit words / non-normalized tokens may
    # survive into the rendered conf — those are exactly the failure surfaces
    # that crashed `nginx -T` in the documented incident.
    for forbidden in ("hours", "minutes", "12h", "5 minutes"):
        assert forbidden not in conf, f"Rendered conf must not contain non-normalized substring {forbidden!r}: {conf}"


def test_create_nginx_server_conf_renders_two_tier_fail_fast(tmp_path: Path, mocker):
    """
    Given:  a default params dict.
    When:   create_nginx_server_conf renders the nginx server config.
    Then:   the rendered conf implements the TWO-TIER fail-fast design:
              * a per-URI limit_conn zone keyed on $request_uri,
              * Tier 2 rejects concurrent same-URI cold-miss fetches (limit_conn 1
                + limit_conn_status 429) and does NOT cache,
              * Tier 1 turns OFF the inherited cache lock so misses fall through to
                Tier 2 instead of queueing.
    """
    from NGINXApiModule import create_nginx_server_conf

    mocker.patch.object(demisto, "callingContext", return_value={"context": {}})
    conf_file = str(tmp_path / "nginx-test-server.conf")
    create_nginx_server_conf(conf_file, 12345, params={})
    with open(conf_file) as f:
        conf = f.read()

    # Per-URI concurrency zone (keyed on the resource, not the client).
    assert "limit_conn_zone $request_uri zone=concurrent_conn_zone:1m;" in conf
    # Tier 2 fail-fast: one build per URI, extras rejected with 429, no caching.
    assert "limit_conn concurrent_conn_zone 1;" in conf
    assert "limit_conn_status 429;" in conf
    assert "proxy_cache off;" in conf
    # Tier 1 must DISABLE the inherited cache lock so cold misses reach Tier 2
    # (the fail-fast limiter) instead of queueing behind proxy_cache_lock.
    assert "proxy_cache_lock off;" in conf
    # The queueing lock must NOT be enabled anywhere as a directive.
    assert "proxy_cache_lock on;" not in conf


def test_create_nginx_server_conf_never_caches_transient_statuses(tmp_path: Path, mocker):
    """
    Given:  a default params dict.
    When:   create_nginx_server_conf renders the nginx server config.
    Then:   transient responses are excluded from caching so a slow/failed build
            cannot poison the URI: the fail-fast 429 and the upstream 5xx/504
            family both get "0s" validity (overriding the `any` catch-all), while
            proxy_cache_use_stale + background_update still allow serving a GOOD
            stale copy on timeout.
    """
    from NGINXApiModule import create_nginx_server_conf

    mocker.patch.object(demisto, "callingContext", return_value={"context": {}})
    conf_file = str(tmp_path / "nginx-test-server.conf")
    create_nginx_server_conf(conf_file, 12345, params={})
    with open(conf_file) as f:
        conf = f.read()

    # Never cache the fail-fast rejection or upstream errors/timeouts.
    assert "proxy_cache_valid 429 0s;" in conf
    assert "proxy_cache_valid 500 502 503 504 0s;" in conf
    # Stale-serving of a previously-cached GOOD copy is still enabled.
    assert "proxy_cache_use_stale" in conf
    assert "proxy_cache_background_update on;" in conf


def test_create_nginx_server_conf_two_server_blocks_and_ports(tmp_path: Path, mocker):
    """
    Given:  a listening port of 12345.
    When:   create_nginx_server_conf renders the nginx server config.
    Then:   two server blocks are emitted — Tier 1 public on the given port, and
            Tier 2 internal on loopback at fetchport (= serverport + 1 = port + 2)
            — and Tier 2 proxies on to the gevent app at serverport (= port + 1).
    """
    from NGINXApiModule import create_nginx_server_conf

    mocker.patch.object(demisto, "callingContext", return_value={"context": {}})
    conf_file = str(tmp_path / "nginx-test-server.conf")
    port = 12345
    create_nginx_server_conf(conf_file, port, params={})
    with open(conf_file) as f:
        conf = f.read()

    serverport = port + 1  # gevent app
    fetchport = serverport + 1  # Tier-2 internal fetch server

    # Exactly two `server {` blocks (Tier 1 + Tier 2).
    assert conf.count("server {") == 2
    # Tier 1 listens on the public port; Tier 2 on loopback:fetchport.
    assert f"listen {port} default_server" in conf
    assert f"listen localhost:{fetchport};" in conf
    # Tier 1 proxies misses to Tier 2; Tier 2 proxies to the gevent app.
    assert f"proxy_pass http://localhost:{fetchport}/;" in conf
    assert f"proxy_pass http://localhost:{serverport}/;" in conf


def test_create_nginx_server_conf_rejects_invalid_param(tmp_path: Path, mocker, monkeypatch):
    """
    Given:  a params dict with an unparseable value for cache_404_ttl.
    When:   create_nginx_server_conf is called.
    Then:   a DemistoException naming the offending param is raised BEFORE any
            subprocess (i.e. before `nginx -T`) is invoked — the failure is
            surfaced fast and pointed at the right field.
    """
    from NGINXApiModule import create_nginx_server_conf

    mocker.patch.object(demisto, "callingContext", return_value={"context": {}})

    # If create_nginx_server_conf ever drifted to invoke subprocess on bad input,
    # this fail-loud monkeypatch surfaces the regression instead of hiding it.
    def _fail_loudly(*_args, **_kwargs):
        raise AssertionError("subprocess.check_output must not be called for invalid params")

    monkeypatch.setattr(subprocess, "check_output", _fail_loudly)

    conf_file = str(tmp_path / "nginx-test-server.conf")
    with pytest.raises(DemistoException, match="cache_404_ttl"):
        create_nginx_server_conf(conf_file, 12345, params={"cache_404_ttl": "garbage"})