miti.sh/posts/2025-06-22-test-nginx-conf-directives.md

11 KiB

{ title: "Test nginx.conf Directives (subtitle: With MoonScript, OpenResty, and Busted)" } $index

Introduction

We'll need nginx and luarocks. Buildpack has luarocks installed.

$ docker pull openresty/openresty:bookworm-buildpack

Start a server on localhost:

$ docker run --rm -it -p 80:80 openresty/openresty:bookworm-buildpack

Visit localhost in browser. Should see OpenResty splash page.

OpenResty default nginx index page

Prepare directory layout

$ mkdir -p logs/ conf/conf.d/ html/

Copy config file

$ docker run --rm -it -w /opt -v $PWD:/opt openresty/openresty:bookworm-buildpack \
cp /etc/nginx/conf.d/default.conf /opt/conf.d/

edit default.conf change root /usr/local/openresty/nginx/html; to:

root /var/www;

html/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title></title>
  </head>
  <body>
    hello world!
  </body>
</html>

Start nginx:

$ docker run --rm -it -p 80:80 \
-v $PWD/conf/conf.d:/etc/nginx/conf.d -v $PWD/html:/var/www \
openresty/openresty:bookworm-buildpack

Then, in another console:

$ curl -v localhost
*   Trying 127.0.0.1:80...
* Connected to localhost (127.0.0.1) port 80 (#0)
> GET / HTTP/1.1
> Host: localhost
> User-Agent: curl/7.88.1
> Accept: */*
> 
< HTTP/1.1 200 OK
< Server: openresty/1.27.1.2
< ...
< 
<!DOCTYPE html>
<html lang="en">
  ...
  <body>
    hello world!
  </body>
</html>

If we wanted to write a test for that, we need some packages from luarocks.

Dockerfile

FROM openresty/openresty:bookworm-buildpack

WORKDIR /opt/app

# needed for testing
RUN luarocks install busted
RUN luarocks install luajit-curl
RUN luarocks install luasocket # needed for testing nginx reverse proxy
$ docker build -t test-nginx .
$ mkdir spec

spec/nginx_spec.moon

http = require "luajit-curl-helper.http"

req = (url) ->
  request = http.init url
  st = request\perform!
  error request\lastError! if not st
  request

describe "http://localhost", ->
  it "sends /index.html", ->
    request = req "http://localhost"
    assert.same request\statusCode!, 200
    assert.same request\statusMessage!, "OK"
    assert.same request\body!\match("<body>%s+(.-)%s+</body>"), "hello world!"

Start the test server:

$ ct=$(docker run --rm -d \
-v $PWD/conf/conf.d:/etc/nginx/conf.d \
-v $PWD/html:/var/www \
-v $PWD:/opt/app \
test-nginx)

Run the tests.

$ docker exec -t $ct busted
1 success / 0 failures / 0 errors / 0 pending : 0.008246 seconds

Stop the test server.

$ docker exec -t $ct openresty -s stop

Edit hosts

Instead of localhost we'd like to use an actual domain name. We can do this with the --add-host option. But before we do that, we want to make sure our container does not have access to the internet, otherwise we might unintentionally get a response from a domain's server on the internet rather than from our test server.

$ docker network create --internal no-internet

Now we can start the test server with our host:

$ ct=$(docker run --rm -d \
-v $PWD/conf/conf.d:/etc/nginx/conf.d \
-v $PWD/html:/var/www \
-v $PWD:/opt/app \
--network no-internet \
--add-host=domain.abc=127.0.0.1 \
test-nginx)

Update our test

request = req "http://localhost"

to

request = req "http://domain.abc"

Run the tests.

$ docker exec -t $ct busted
1 success / 0 failures / 0 errors / 0 pending : 0.008246 seconds

Stop the test server.

$ docker exec -t $ct openresty -s stop

Add a test to make sure the test server is offline

describe "test environment", ->
  it "can't connect to the internet", ->
    assert.has_error (-> req "http://example.org"),
      "Couldn't resolve host name"

Create a Makefile

Let's create a Makefile to make running all these commands easier.

Makefile

image = test-nginx
loopback = 127.0.0.1

image-build:
	docker build -t $(image) .

image-rm:
	docker image rm $(image)

test:
	@ct=$(shell docker run --rm -d \
	   -v $(PWD)/conf/conf.d:/etc/nginx/conf.d \
	   -v $(PWD)/html:/var/www \
	   -v $(PWD):/opt/app \
	   --network no-internet \
	   --add-host=domain.abc=$(loopback) \
	   $(image)); \
	docker exec -t $$ct busted; \
	docker exec $$ct openresty -s stop

Now we can run tests by running make test.

$ make test
●●
2 successes / 0 failures / 0 errors / 0 pending : 0.008812 seconds

SSL

We want our server to redirect all http requests to https.

Our test:

describe "http://domain.abc", ->
  it "redirects to https", ->
    request = req "http://domain.abc"
    assert.same request\statusCode!, 301
    assert.same request\statusMessage!, "Moved Permanently"
    assert.same request\header!.Location, "https://domain.abc/"
$ make test
●●◼
2 successes / 1 failure / 0 errors / 0 pending : 0.010449 seconds

Failure → .../luajit/lib/luarocks/rocks-5.1/busted/2.2.0-1/bin/busted @ 3
http://domain.abc redirects to https
spec/nginx_spec.moon:24: Expected objects to be the same.
Passed in:
(number) 301
Expected:
(number) 200

Make self-signed certs in Dockerfile:

RUN openssl req -x509 -newkey rsa:4096 -nodes \
      -keyout /etc/ssl/private/domain.abc.pem \
      -out /etc/ssl/certs/domain.abc.pem \
      -sha256 -days 365 -subj '/CN=domain.abc' \
      -addext "subjectAltName=DNS:domain.abc"

Edit default.conf:

server {
    listen 80;
    return 301 https://$host$request_uri;
}

server {
    listen       443 ssl;
    server_name  domain.abc;
    ssl_certificate /etc/ssl/certs/domain.abc.pem;
    ssl_certificate_key /etc/ssl/private/domain.abc.pem;

Rebuild the image:

$ make image-rm image-build

Run tests:

$ make test
●◼●
2 successes / 1 failure / 0 errors / 0 pending : 0.009618 seconds

Failure → .../luajit/lib/luarocks/rocks-5.1/busted/2.2.0-1/bin/busted @ 3
http://domain.abc sends /index.html
spec/nginx_spec.moon:17: Expected objects to be the same.
Passed in:
(number) 200
Expected:
(number) 301

Fix test:

describe "https://domain.abc", ->
  it "sends /index.html", ->
    request = req "https://domain.abc"

Run tests:

$ make test
●●●
3 successes / 0 failures / 0 errors / 0 pending : 0.017065 seconds

Reverse proxy a subdomain to a unix socket

Add to default.conf:

server {
    listen 443 ssl;
    server_name git.domain.abc;

    location / {
        client_max_body_size 1024M;
        proxy_pass http://unix:/run/gitea/gitea.socket;
        proxy_set_header Connection $http_connection;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Add subdomain to certs in Dockerfile:

-addext "subjectAltName=DNS:domain.abc,DNS:git.domain.abc"

Add a test socket server (copied and modified from here):

::: filename-for-code-block spec/unixstreamsrvr.moon :::

socket = require "socket"
socket.unix = require "socket.unix"
u = assert socket.unix.stream!
assert u\bind "/run/gitea/gitea.socket"
assert u\listen!
assert u\settimeout 1
c = assert u\accept!

while true
  m = assert c\receive!
  break if m == ""
  print m

Add a spec:

describe "https://git.domain.abc", ->
  it "reverse-proxy's a subdomain request to a unix socket", ->
    socket = fname: "unixstreamsrvr.moon", dir: "/run/gitea", owner: "nobody"
    basepath = debug.getinfo(1).short_src\match"^(.*)/[^/]*$" or "."
    seconds = 0.1

    os.execute "install -o #{socket.owner} -d #{socket.dir}"
    cmd = "su -s /bin/bash -c 'moon %s' %s"
    server = io.popen cmd\format "#{basepath}/#{socket.fname}", socket.owner
    os.execute "sleep #{seconds}" -- wait for server to start
    f = io.popen "find #{socket.dir} -type s -ls", "r"
    result = with f\read "*a"
      f\close!
    assert.truthy result\match "nobody%s+nogroup.+#{socket.dir}/gitea.socket"

    req "https://git.domain.abc"

    reqheader = with server\read "*a"
      server\close!

    assert.truthy reqheader\match "Host: git.domain.abc"

Edit Makefile:

--add-host=git.domain.abc=$(loopback) \

Rebuild image:

$ make image-rm image-build

Run tests:

$ make test
●●●●
4 successes / 0 failures / 0 errors / 0 pending : 0.131619 seconds

Conclusion

Bonus!: Issues We Ran Into Just Trying To Make This Post

$host$request_uri

::: filename-for-code-block renderers/markdown.moon :::

dollar_temp = "0000sitegen_markdown00dollar0000"

So, when two cosmo selectors had no space between them, it was ambiguous to which selector the numbers belonged.

0000sitegen_markdown00dollar0000.10000sitegen_markdown00dollar0000.2 

Solution was to change the first 0 to a letter z:

z000sitegen_markdown00dollar0000.1z000sitegen_markdown00dollar0000.2 

Because dollar_temp is a private attribute, I had to copy every function from the sitegen markdown renderer. Which makes my renderer a copy with some additions.

::: filename-for-code-block spec/renderers_spec.moon :::

import escape_cosmo, unescape_cosmo from require "renderers.markdown"

it "escapes and unescapes adjacent cosmo selectors", ->
 str = "$one$two"
 escaped = escape_cosmo str
 assert.same escaped,
   "z000sitegen_markdown00dollar0000.1z000sitegen_markdown00dollar0000.2"
 assert.same str, (unescape_cosmo escape_cosmo str)

$$ct

z000sitegen_markdown00dollar0000.1

Because . is not a valid character for a variable name, when this string is syntax-highlighted, it gets split, like this:

<span>z000sitegen_markdown00dollar0000</span>.1

The string is then 'unescaped', but because the string got split by the closing </span> tag, it will no longer match the pattern and the 'unescape' fails.

The solution was to change the . in the dollar temp pattern to _, which is a valid character in a variable name. Update the $one$two spec escaped string.

::: filename-for-code-block spec/renderers_spec.moon :::

it "escapes and unescapes double dollar signs", ->
  out = flatten_html render [[
```Makefile
  $$name
```]]

  assert.same [[<div class="highlight"><pre><span></span><code><span class="py-w"></span><span class="py-nv">$$name</span></code></pre></div>]], out