diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3fa090c..4bd30f0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,8 +1,7 @@ name: lua-resty-netacea-build on: + workflow_dispatch: pull_request: - branches: - - master push: branches: - master diff --git a/.luacov b/.luacov new file mode 100644 index 0000000..799c435 --- /dev/null +++ b/.luacov @@ -0,0 +1,6 @@ +return { + ["reporter"] = "html", + ["reportfile"] = "luacov.report.html", + ["include"] = {"./src/" }, + runreport = true +} \ No newline at end of file diff --git a/README.md b/README.md index e8aca52..94e6c09 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,42 @@ # lua_resty_netacea -An Openresty module for easy integration of Netacea services +An Openresty module for easy integration of Netacea services. This repo is for developing the package. The package can be accessed by the Luarocks package management platform. See the Netacea documentation for making use of the module. -# Building the base image -All the images used by docker rely on a specific base image being available on your local docker registry. You can ensure you have this by running the following command -```sh -docker build -t lua_resty_netacea:latest . -``` +## Published package + +The Netacea package is available on the Luarocks package manager. Publishing is handled by the Netacea team. + +## Docker images +The Dockerfile contains a multi-stage build, including: + +| Stage name | Based on | Description | +| -- | -- | -- | +| base | openresty/openresty:noble | Base image of Openresty with updated packages around openSSL | +| build | base | Working Openresty instance with Netacea plugin installed using luarocks and rockspec file | +| test | build | Lua packages installed for testing and linting. Command overridden to run unit tests | +| lint | test | Command overridden to run luacheck linter and output results | + +The docker compose file is used to mount local files to the right place in the image to support development. + +### Run development version + +1. Update `./src/conf/nginx.conf` to include Netacea configuration and server configuration. Default is the NGINX instance will just return a static "Hello world" page. See "Configuration" below +2. `docker-compose up resty` +3. Access [](http://localhost:8080) + +### Run tests + +#### Unit tests + +Without coverage report: `docker-compose run test` +With coverage report (sent to stdout) `docker-compose run -e LUACOV_REPORT=1 test [> output.html]` + +#### Linter + +`docker-compose run linter` -# Running Tests -`docker-compose build` then `docker-compose run test` +## Configuration -## nginx.conf - mitigate +### nginx.conf - mitigate ``` worker_processes 1; @@ -57,7 +83,7 @@ http { } ``` -## nginx.conf - inject +### nginx.conf - inject ``` worker_processes 1; diff --git a/docker-compose.yml b/docker-compose.yml index b85e847..7abd51c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,8 @@ services: volumes: - "./src:/usr/src/src" - "./test:/usr/src/test" + - "./run_lua_tests.sh:/usr/src/run_lua_tests.sh" + - ".luacov:/usr/src/.luacov" lint: build: diff --git a/run_lua_tests.sh b/run_lua_tests.sh old mode 100644 new mode 100755 index 2a6ad1f..2d1d068 --- a/run_lua_tests.sh +++ b/run_lua_tests.sh @@ -1,80 +1,7 @@ -# sh /docker/pull-changes.sh -OPENRESTY="/usr/local/openresty" -RESTY="${OPENRESTY}/bin/resty" -WD="${OPENRESTY}/nginx" -BASE_DIR="/usr/src" -TEST_DIR="${BASE_DIR}/test" - -STATS_FILE="luacov.stats.out" -STATS_SRC="${TEST_DIR}/${STATS_FILE}" -REPORT_FILE="luacov.report.out" -REPORT_SRC="${TEST_DIR}/${REPORT_FILE}" - -EXIT_CODE=0 - -################################################################################ - -OPT_PROCESS_STATS=0 -OPT_EARLY_EXIT=1 - -while getopts "s" opt; do - case $opt in - s) OPT_PROCESS_STATS=1;; - \?) echo "invalid argument";; - esac -done - -################################################################################ - -function exit_script { - echo "" - echo "END TESTS" - echo "" - echo "coverage stats file: ${STATS_SRC}" - end_tests - echo $1 - exit $1 -} - -function end_tests { - echo "done" - # if [ $OPT_PROCESS_STATS -eq 1 ]; then - # cd $TEST_DIR - # (luacov) - # echo "coverage report file: ${REPORT_SRC}" - # fi -} - - ################################################################################ - -echo "" -echo "BEGIN TESTS" -echo "" - -cd $TEST_DIR -PREV=$(pwd) - -files=$(find . -name '*.test.lua') - -while read line; do - echo " -- TEST FILE: ${line}" - DIR=$(dirname "${line}") - FILE=$(basename "${line}") - - onlytag="" - grep '#only' "${line}" && onlytag="--tags='only'" - - bash -c "$RESTY $line --exclude-tags='skip' ${onlytag}" - RES=$? - - if [ $RES -ne 0 ]; then - EXIT_CODE=$RES - if [ $OPT_EARLY_EXIT -eq 1 ]; then break; fi - fi - - cd "$PREV" - echo "" -done <<< "$files" - -exit_script $EXIT_CODE +if [ "$LUACOV_REPORT" = "1" ]; then + busted --coverage-config-file ./.luacov --coverage ./test >&2 + cat ./luacov.report.html +else + busted ./test +fi \ No newline at end of file diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index 2f3d979..c7be502 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -4,6 +4,7 @@ local Ingest = require("lua_resty_netacea_ingest") local netacea_cookies = require('lua_resty_netacea_cookies_v3') local utils = require("netacea_utils") local protector_client = require("lua_resty_netacea_protector_client") +local Constants = require("lua_resty_netacea_constants") local _N = {} _N._VERSION = '0.2.2' diff --git a/src/lua_resty_netacea_constants.lua b/src/lua_resty_netacea_constants.lua index f9f40ce..31d0fb0 100644 --- a/src/lua_resty_netacea_constants.lua +++ b/src/lua_resty_netacea_constants.lua @@ -1,4 +1,4 @@ -Constants = {} +local Constants = {} Constants['idTypesText'] = {} Constants['idTypes'] = { diff --git a/src/lua_resty_netacea_cookies_v3.lua b/src/lua_resty_netacea_cookies_v3.lua index 9886ec4..5a74a54 100644 --- a/src/lua_resty_netacea_cookies_v3.lua +++ b/src/lua_resty_netacea_cookies_v3.lua @@ -49,6 +49,7 @@ end function NetaceaCookies.generateNewCookieValue(secretKey, client, user_id, cookie_id, issue_reason, issue_timestamp, grace_period, match, mitigation, captcha, settings) + settings = settings or {} local plaintext = ngx.encode_args({ cip = client, uid = user_id, @@ -105,7 +106,18 @@ function NetaceaCookies.parseMitataCookie(cookie, secretKey) end end - if tonumber(decoded.ist) + tonumber(decoded.grp) < ngx.time() then + -- Validate numeric fields + local ist = tonumber(decoded.ist) + local grp = tonumber(decoded.grp) + + if not ist or not grp then + return { + valid = false, + reason = constants['issueReasons'].INVALID_SESSION + } + end + + if ist + grp < ngx.time() then return { valid = false, user_id = decoded.uid, diff --git a/src/silence_g_write_guard.lua b/src/silence_g_write_guard.lua new file mode 100644 index 0000000..d300f93 --- /dev/null +++ b/src/silence_g_write_guard.lua @@ -0,0 +1,3 @@ +-- Some QOL patches for warnings from g_write_guard. +-- See https://github.com/openresty/lua-nginx-module/issues/1558#issuecomment-512360451 +rawset(_G, 'lfs', false) -- silence g_write_guard about lfs module in busted \ No newline at end of file diff --git a/test/lua_resty_netacea.test.lua b/test/lua_resty_netacea.test.lua deleted file mode 100644 index f63b690..0000000 --- a/test/lua_resty_netacea.test.lua +++ /dev/null @@ -1,1478 +0,0 @@ -require 'busted.runner'() - -package.path = "../src/?.lua;" .. package.path - --- luacov is disabled because this runner causes the test to hang after completion. --- Need to take another look at this in future. --- local runner = require 'luacov.runner' --- runner.tick = true --- runner.init({savestepsize = 3}) --- jit.off() - -local COOKIE_DELIMITER = '_/@#/' - -local netacea_default_params = { - ingestEndpoint = 'ingest.endpoint', - mitigationEndpoint = 'mitigation.endpoint', - apiKey = 'api-key', - secretKey = 'secret-key', - realIpHeader = '', - ingestEnabled = true, - mitigationEnabled = true, - mitigationType = 'MITIGATE' -} - -local function copy_table(orig, overrides) - local copy = {} - for orig_key, orig_value in pairs(orig) do - copy[orig_key] = orig_value - end - if (overrides) then - for override_key, override_value in pairs(overrides) do - copy[override_key] = override_value - end - end - return copy -end - -local function wrap_table(table, proxy_table) - setmetatable(proxy_table, { - __index = function(_, key) - return table[key] - end, - __newindex = function(_, key, value) - table[key] = value - end - }) - return proxy_table -end - -local function build_mitata_cookie(epoch, uid, mitigation_values, key) - local hmac = require 'openssl.hmac' - local base64 = require 'base64' - local netacea = require 'lua_resty_netacea' - local netacea_cookies = require 'lua_resty_netacea_cookies' - - local value = epoch .. COOKIE_DELIMITER .. uid .. COOKIE_DELIMITER .. mitigation_values - local hash = hmac.new(key, 'sha256'):final(value) - hash = netacea_cookies.bToHex(hash) - hash = base64.encode(hash) - - return hash .. COOKIE_DELIMITER .. value -end - -local function generate_uid() - return '0000000012345678' -end - -local function setHttpResponse(expectedUrl, response, err) - package.loaded['http'] = nil - local http_mock = require('resty.http') - - local request_uri_spy = spy.new(function(_, url, _) - if (expectedUrl) then - assert(url == expectedUrl) - end - return response, err - end) - - local set_timeouts_spy = spy.new(function(_self, connect_timeout, send_timeout, read_timeout) - assert.is.equal(connect_timeout, 500, 'connect_timeout is set') - assert.is.equal(send_timeout, 750, 'send_timeout is set') - assert.is.equal(read_timeout, 750, 'read_timeout is set') - end) - - http_mock.new = function() - return { - request_uri = request_uri_spy, - set_timeouts = set_timeouts_spy - } - end - - package.loaded['http'] = http_mock - - return request_uri_spy -end - -insulate("lua_resty_netacea.lua", function() - describe('new', function() - - it('returns an object with ingest and mitigation enabled on initialisation', function() - local netacea = (require 'lua_resty_netacea'):new(netacea_default_params) - assert.is_true(netacea.mitigationEnabled) - assert.is_true(netacea.ingestEnabled) - end) - - it('sets endpoint index to 0 on initialisation', function() - local netacea = (require 'lua_resty_netacea'):new(netacea_default_params) - assert.is.equal(netacea.endpointIndex, 0) - end) - - it('sets mitigationEnabled to false if mitigationEndpoint is an empty string', function() - local params = copy_table(netacea_default_params) - params.mitigationEndpoint = '' - local netacea = (require 'lua_resty_netacea'):new(params) - assert.is_false(netacea.mitigationEnabled) - end) - - it('sets mitigationEnabled to false if mitigationEndpoint is nil', function() - local params = copy_table(netacea_default_params) - params.mitigationEndpoint = nil - local netacea = (require 'lua_resty_netacea'):new(params) - assert.is_false(netacea.mitigationEnabled) - end) - - it('sets mitigationEnabled to false if mitigationEndpoint is nil', function() - local params = copy_table(netacea_default_params) - params.mitigationEndpoint = {} - local netacea = (require 'lua_resty_netacea'):new(params) - assert.is_false(netacea.mitigationEnabled) - end) - - it('sets mitigationEnabled to false if mitigationType is nil', function() - local params = copy_table(netacea_default_params) - params.mitigationType = {} - local netacea = (require 'lua_resty_netacea'):new(params) - assert.is_false(netacea.mitigationEnabled) - end) - - it('sets mitigationEnabled to false if mitigationType is not MITIGATE or INJECT', function() - local paramsMitigate = copy_table(netacea_default_params) - paramsMitigate.mitigationType = 'MITIGATE' - local netaceaMitigate = (require 'lua_resty_netacea'):new(paramsMitigate) - assert.is_true(netaceaMitigate.mitigationEnabled) - - local paramsInject = copy_table(netacea_default_params) - paramsInject.mitigationType = 'INJECT' - local netaceaInject = (require 'lua_resty_netacea'):new(paramsInject) - assert.is_true(netaceaInject.mitigationEnabled) - - local paramsOther = copy_table(netacea_default_params) - paramsOther.mitigationType = 'wefwwg' - local netaceaOther = (require 'lua_resty_netacea'):new(paramsOther) - assert.is_false(netaceaOther.mitigationEnabled) - end) - - it('sets mitigationEndpoint if an array is provided', function() - local endpointArray = { 'mitigation.endpoint', 'mitigation2.endpoint' } - local netacea = (require 'lua_resty_netacea'):new( - copy_table( - netacea_default_params, - { mitigationEndpoint = endpointArray } - ) - ) - assert.are.same(netacea.mitigationEndpoint, endpointArray) - end) - - it('sets mitigationEndpoint to an array if a string is provided', function() - local endpoint = 'mitigation.endpoint' - local netacea = (require 'lua_resty_netacea'):new( - copy_table( - netacea_default_params, - { mitigationEndpoint = endpoint } - ) - ) - assert.are.same(netacea.mitigationEndpoint, { endpoint }) - end) - - it('sets the module version and type', function() - local netacea = (require 'lua_resty_netacea'):new(netacea_default_params) - assert.is.equal(netacea._MODULE_VERSION, '0.2.2') - assert.is.equal(netacea._MODULE_TYPE, 'nginx') - end) - end) - - describe('get_mitata_cookie', function() - - local netacea_init_params = { - ingestEndpoint = '', - mitigationEndpoint = '', - apiKey = '', - secretKey = 'super_secret', - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - } - - it('returns nil if cookie is not present', function() - local ngx_stub = require 'ngx' - ngx_stub.var = { - cookie__mitata = '', - } - package.loaded['ngx'] = ngx_stub - - local netacea = (require 'lua_resty_netacea'):new(netacea_init_params) - - local result = netacea:get_mitata_cookie() - - assert(result == nil) - end) - - it('returns nil if the cookie expiration is not in the future', function() - local ngx_stub = require 'ngx' - local t = ngx_stub.time() - 20 - ngx_stub.var = { - cookie__mitata = build_mitata_cookie(t, generate_uid(), '000', netacea_init_params.secretKey, '000') - } - package.loaded['ngx'] = ngx_stub - - local netacea = (require 'lua_resty_netacea'):new(netacea_init_params) - - local result = netacea:get_mitata_cookie() - assert(result == nil) - end) - - it('returns nil if the userID is not present', function() - local ngx_stub = require 'ngx' - local t = ngx_stub.time() + 20 - ngx_stub.var = { - cookie__mitata = build_mitata_cookie(t, '', '000', netacea_init_params.secretKey) - } - package.loaded['ngx'] = ngx_stub - - local netacea = (require 'lua_resty_netacea'):new(netacea_init_params) - - local result = netacea:get_mitata_cookie() - assert(result == nil) - end) - - it('returns nil if the hash value does not match', function() - local ngx_stub = require 'ngx' - local t = ngx_stub.time() + 20 - ngx_stub.var = { - cookie__mitata = 'invalid_hash' .. COOKIE_DELIMITER .. t .. COOKIE_DELIMITER .. generate_uid() - } - package.loaded['ngx'] = ngx_stub - - local netacea = (require 'lua_resty_netacea'):new(netacea_init_params) - - local result = netacea:get_mitata_cookie() - assert(result == nil) - end) - - it('returns nil if the cookie is invalid', function() - local ngx_stub = require 'ngx' - ngx_stub.var = { - cookie__mitata = 'someinvalidcookie' - } - package.loaded['ngx'] = ngx_stub - - local netacea = (require 'lua_resty_netacea'):new(netacea_init_params) - - local result = netacea:get_mitata_cookie() - assert(result == nil) - end) - - it('returns the parsed cookie if the cookie is valid', function() - local ngx_stub = require 'ngx' - local t = ngx_stub.time() + 20 - local cookie = build_mitata_cookie(t, generate_uid(), '000', netacea_init_params.secretKey) - ngx_stub.var = { - cookie__mitata = cookie - } - package.loaded['ngx'] = ngx_stub - - local netacea = (require 'lua_resty_netacea'):new(netacea_init_params) - - local hash, epoch, uid, mitigation = cookie:match('(.*)_/@#/(.*)_/@#/(.*)_/@#/(.*)') - local expected = { - original = ngx_stub.var.cookie__mitata, - hash = hash, - epoch = tonumber(epoch), - uid = uid, - mitigation = mitigation - } - local result = netacea:get_mitata_cookie() - assert.are.same(expected, result) - end) - - it('works with custom cookie names', function() - local ngx_stub = require 'ngx' - local t = ngx_stub.time() + 20 - local custom_cookie_name = 'custom_mitata' - local cookie = build_mitata_cookie(t, generate_uid(), '000', netacea_init_params.secretKey) - ngx_stub.var = { - ['cookie_' .. custom_cookie_name] = cookie - } - package.loaded['ngx'] = ngx_stub - - local netacea = (require 'lua_resty_netacea'):new( - copy_table( - netacea_init_params, - { cookieName = custom_cookie_name } - ) - ) - - local hash, epoch, uid, mitigation = cookie:match('(.*)_/@#/(.*)_/@#/(.*)_/@#/(.*)') - local expected = { - original = ngx_stub.var['cookie_' .. custom_cookie_name], - hash = hash, - epoch = tonumber(epoch), - uid = uid, - mitigation = mitigation - } - local result = netacea:get_mitata_cookie() - assert.are.same(expected, result) - - end) - end) - - describe('mitigate', function() - local match = require('luassert.match') - local mit_svc_url = 'someurl' - local mit_svc_url_captcha = mit_svc_url .. '/AtaVerifyCaptcha' - local mit_svc_api_key = 'somekey' - local mit_svc_secret = 'somesecret' - local ngx = nil - - local function stubNgx() - local ngx_stub = {} - - ngx_stub.var = { - http_user_agent = 'some_user_agent', - remote_addr = 'some_remote_addr', - cookie__mitata = 'some_mitata_cookie', - request_uri = '-' - } - ngx_stub.ctx = { - } - ngx_stub.req = { - read_body = function() return nil end, - get_body_data = function() return nil end - } - - ngx_stub.header = {} - ngx_stub.status = 0 - ngx_stub.HTTP_FORBIDDEN = 402 - - ngx_stub.exit = spy.new(function(_, _) return nil end) - ngx_stub.print = spy.new(function(_, _) return nil end) - - ngx = wrap_table(require 'ngx', ngx_stub) - package.loaded['ngx'] = ngx - end - - before_each(function() - package.loaded['lua_resty_netacea'] = nil - - stubNgx() - end) - - it('forwards to mit svc if mitata cookie check fails', function() - local req_spy = setHttpResponse(mit_svc_url, nil, 'error') - - local netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - netacea.get_mitata_cookie = spy.new(function () return nil end) - - netacea:run() - - local _ = match._ - - assert.spy(req_spy).was.called(1) - assert.spy(req_spy).was.called_with(_, mit_svc_url, { - method = 'GET', - headers = { - ['x-netacea-api-key'] = mit_svc_api_key, - ['content-type'] = 'application/x-www-form-urlencoded', - ['user-agent'] = ngx.var.http_user_agent, - ['x-netacea-client-ip'] = ngx.var.remote_addr, - ["cookie"] = "_mitata=" .. ngx.var.cookie__mitata .. ';_mitatacaptcha=' - } - }) - end) - - it('does not forward to mit svc if mitata cookie is valid', function() - local req_spy = setHttpResponse('-', nil, 'error') - - local netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local cookie = { - mitigation = "000" - } - - netacea.get_mitata_cookie = spy.new(function () return cookie end) - - netacea:run() - - assert.spy(req_spy).was.not_called() - end) - - it('does not forward to mit service if mitata cookie is ALLOW', function() - local req_spy = setHttpResponse('-', nil, 'error') - - local netacea = require 'lua_resty_netacea' - local mit = netacea.idTypes.IP .. netacea.mitigationTypes.ALLOW .. netacea.captchaStates.NONE - ngx.var.cookie__mitata = build_mitata_cookie(ngx.time() + 20, generate_uid(), mit, mit_svc_secret) - - package.loaded['lua_resty_netacea'] = nil - netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local logFunc = spy.new(function(res) - assert.equal(netacea.idTypes.IP, res.idType) - assert.equal(netacea.mitigationTypes.ALLOW, res.mitigationType) - assert.equal(netacea.captchaStates.NONE, res.captchaState) - end) - - netacea:run(logFunc) - - assert.spy(req_spy).was.not_called() - assert.spy(logFunc).was.called() - end) - - - - it('works with custom cookies', function() - local custom_cookie_name = 'custom_mitata' - local req_spy = setHttpResponse('-', nil, 'error') - - local netacea = require 'lua_resty_netacea' - local mit = netacea.idTypes.IP .. netacea.mitigationTypes.ALLOW .. netacea.captchaStates.NONE - ngx.var["cookie_" .. custom_cookie_name] = build_mitata_cookie(ngx.time() + 20, generate_uid(), mit, mit_svc_secret) - - package.loaded['lua_resty_netacea'] = nil - - netacea = (require 'lua_resty_netacea'):new( - copy_table( - netacea_default_params, - { - ingestEnabled = false, - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - cookieName = custom_cookie_name - } - ) - ) - - local logFunc = spy.new(function(res) - assert.equal(netacea.idTypes.IP, res.idType) - assert.equal(netacea.mitigationTypes.ALLOW, res.mitigationType) - assert.equal(netacea.captchaStates.NONE, res.captchaState) - end) - - netacea:run(logFunc) - - assert.spy(req_spy).was.not_called() - assert.spy(logFunc).was.called() - end) - - it('does not forward to mit service if mitata cookie is BLOCK', function() - local req_spy = setHttpResponse('-', nil, 'error') - - local netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local mit = netacea.idTypes.IP .. netacea.mitigationTypes.BLOCKED .. netacea.captchaStates.NONE - ngx.var.cookie__mitata = build_mitata_cookie(ngx.time() + 20, generate_uid(), mit, mit_svc_secret) - - local logFunc = spy.new(function(res) - assert(res.idType == netacea.idTypes.IP) - assert(res.mitigationType == netacea.mitigationTypes.BLOCKED) - assert(res.captchaState == netacea.captchaStates.NONE) - end) - - netacea:run(logFunc) - - assert.spy(req_spy).was.not_called() - assert(ngx.status == ngx.HTTP_FORBIDDEN) - assert.spy(ngx.print).was.called_with('403 Forbidden') - - assert(ngx.header['Cache-Control'] == 'max-age=0, no-cache, no-store, must-revalidate') - assert.spy(ngx.exit).was.called() - assert.spy(logFunc).was.called() - end) - - it('forwards to mit service if mitata cookie is CAPTCHA SERVE', function() - local expected_captcha_body = 'some captcha body' - local netacea = require 'lua_resty_netacea' - local req_spy = setHttpResponse(mit_svc_url, { - headers = { - ['x-netacea-match'] = netacea.idTypes.IP, - ['x-netacea-mitigate'] = netacea.mitigationTypes.BLOCKED, - ['x-netacea-captcha'] = netacea.captchaStates.SERVE - }, - status = 200, - body = expected_captcha_body - }, nil) - - package.loaded['lua_resty_netacea'] = nil - netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local mit = netacea.idTypes.IP .. netacea.mitigationTypes.BLOCKED .. netacea.captchaStates.SERVE - ngx.var.cookie__mitata = build_mitata_cookie(ngx.time() + 20, generate_uid(), mit, mit_svc_secret) - - local logFunc = spy.new(function(res) - assert(res.idType == netacea.idTypes.IP) - assert(res.mitigationType == netacea.mitigationTypes.BLOCKED) - assert(res.captchaState == netacea.captchaStates.SERVE) - end) - - netacea:run(logFunc) - - assert.spy(req_spy).was.called() - assert(ngx.status == ngx.HTTP_FORBIDDEN) - assert.spy(ngx.print).was.called_with(expected_captcha_body) - assert(ngx.header['Cache-Control'] == 'max-age=0, no-cache, no-store, must-revalidate') - assert.spy(ngx.exit).was.called() - assert.spy(logFunc).was.called() - end) - - it('serves captcha if client is mitigated', function() - local expected_captcha_body = 'some captcha body' - - local netacea = require 'lua_resty_netacea' - - local req_spy = setHttpResponse(mit_svc_url, { - headers = { - ['x-netacea-match'] = netacea.idTypes.IP, - ['x-netacea-mitigate'] = netacea.mitigationTypes.BLOCKED, - ['x-netacea-captcha'] = netacea.captchaStates.SERVE - }, - status = 200, - body = expected_captcha_body - }, nil) - - package.loaded['lua_resty_netacea'] = nil - netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local logFunc = spy.new(function(res) - assert(res.idType == netacea.idTypes.IP) - assert(res.mitigationType == netacea.mitigationTypes.BLOCKED) - assert(res.captchaState == netacea.captchaStates.SERVE) - end) - - netacea:run(logFunc) - - assert(ngx.status == ngx.HTTP_FORBIDDEN) - assert.spy(req_spy).was.called() - assert.spy(ngx.exit).was.called() - assert.spy(ngx.print).was.called_with(expected_captcha_body) - assert.spy(logFunc).was.called() - end) - - it('returns captcha pass state on positive verification', function() - ngx.var.request_uri = 'AtaVerifyCaptcha' - - local netacea = require 'lua_resty_netacea' - - setHttpResponse(mit_svc_url_captcha, { - headers = { - ['x-netacea-mitatacaptcha-value'] = nil, - ['x-netacea-mitatacaptcha-expiry'] = nil, - ['x-netacea-captcha'] = netacea.captchaStates.PASS - }, - status = 200, - body = 'body' - }, nil) - - package.loaded['lua_resty_netacea'] = nil - netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local logFunc = spy.new(function(res) - assert(res.captchaState == netacea.captchaStates.PASS) - end) - - netacea:run(logFunc) - - assert.spy(logFunc).was.called() - end) - - it('returns captcha fail state on negative verification', function() - ngx.var.request_uri = 'AtaVerifyCaptcha' - - local netacea = require 'lua_resty_netacea' - package.loaded['lua_resty_netacea'] = nil - - setHttpResponse(mit_svc_url_captcha, { - headers = { - ['x-netacea-mitatacaptcha-value'] = nil, - ['x-netacea-mitatacaptcha-expiry'] = nil, - ['x-netacea-captcha'] = netacea.captchaStates.FAIL - }, - status = 200, - body = 'body' - }, nil) - - netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local logFunc = spy.new(function(res) - assert(res.captchaState == netacea.captchaStates.FAIL) - end) - - netacea:run(logFunc) - - assert.spy(logFunc).was.called() - end) - - it('returns correct state', function() - local netacea = require 'lua_resty_netacea' - package.loaded['lua_resty_netacea'] = nil - - local testMitigationTypes = netacea.mitigationTypes - local testIdTypes = netacea.idTypes - local testCaptchaStates = netacea.captchaStates - local unknownHeader = 'Q' - testMitigationTypes['UNKNOWN'] = unknownHeader - testIdTypes['UNKNOWN'] = unknownHeader - testCaptchaStates['UNKNOWN'] = unknownHeader - - for _, id in pairs(testIdTypes) do - for _, mit in pairs(testMitigationTypes) do - for _, captcha in pairs(testCaptchaStates) do - local allowed = (mit == netacea.mitigationTypes.NONE or - mit == unknownHeader or - mit == netacea.mitigationTypes.ALLOW or - captcha == netacea.captchaStates.PASS or - captcha == netacea.captchaStates.COOKIEPASS) - - ngx.status = 0 - ngx.exit:clear() - - local req_spy = setHttpResponse(mit_svc_url, { - headers = { - ['x-netacea-match'] = id, - ['x-netacea-mitigate'] = mit, - ['x-netacea-captcha'] = captcha - }, - status = 200 - }, nil) - - package.loaded['lua_resty_netacea'] = nil - netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - - local logFunc = spy.new(function(res) - assert.are.equal(res.idType, id) - assert.are.equal(res.mitigationType, mit) - assert.are.equal(res.captchaState, captcha) - end) - - netacea:run(logFunc) - - if not allowed then - if ngx.status ~= ngx.HTTP_FORBIDDEN then - assert.spy(ngx.exit).was.called_with(ngx.HTTP_FORBIDDEN) - else - assert.are.equal(ngx.HTTP_FORBIDDEN, ngx.status) - assert.spy(ngx.exit).was.called() - end - - assert.spy(logFunc).was.called() - else - if ngx.status ~= ngx.HTTP_FORBIDDEN then - assert.spy(ngx.exit).was_not_called_with(ngx.HTTP_FORBIDDEN) - assert.spy(ngx.exit).was_not.called() - else - assert.are.not_equal(ngx.HTTP_FORBIDDEN, ngx.status) - assert.spy(ngx.exit).was_not.called() - end - end - - assert.spy(req_spy).was.called() - end - end - end - end) - - it('Uses and forwards configured user id variable', function() - local userIdKey = 'customUserIdValue' - local userIdVal = 'someCustomUserId' - - local req_spy = setHttpResponse(mit_svc_url, nil, 'error') - ngx.var[userIdKey] = userIdVal - - local netacea = (require 'lua_resty_netacea'):new({ - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - mitigationEnabled = true, - userIdKey = userIdKey, - mitigationType = 'MITIGATE' - }) - - netacea:run() - - local _ = match._ - assert.spy(req_spy).was.called_with(_, mit_svc_url, { - method = 'GET', - headers = { - ['x-netacea-api-key'] = mit_svc_api_key, - ['user-agent'] = ngx.var.http_user_agent, - ["content-type"] = 'application/x-www-form-urlencoded', - ['x-netacea-client-ip'] = ngx.var.remote_addr, - ["cookie"] = "_mitata=" .. ngx.var.cookie__mitata .. ';_mitatacaptcha=', - ["x-netacea-userid"] = userIdVal - } - }) - end) - - it('Does not send custom id header if var is not set', function() - local userIdKey = 'customUserIdValue' - - local req_spy = setHttpResponse(mit_svc_url, nil, 'error') - - local netacea = (require 'lua_resty_netacea'):new({ - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - mitigationEnabled = true, - userIdKey = userIdKey, - mitigationType = 'MITIGATE' - }) - - netacea:run() - - local _ = match._ - assert.spy(req_spy).was.called_with(_, mit_svc_url, { - method = 'GET', - headers = { - ['x-netacea-api-key'] = mit_svc_api_key, - ['content-type'] = 'application/x-www-form-urlencoded', - ['user-agent'] = ngx.var.http_user_agent, - ['x-netacea-client-ip'] = ngx.var.remote_addr, - ["cookie"] = "_mitata=" .. ngx.var.cookie__mitata .. ';_mitatacaptcha=' - } - }) - end) - - it('converts non-captcha attempt captcha PASS to COOKIEPASS', function() - local netacea = (require 'lua_resty_netacea') - setHttpResponse(mit_svc_url, { - headers = { - ['x-netacea-match'] = netacea.idTypes.IP, - ['x-netacea-mitigate'] = netacea.mitigationTypes.BLOCKED, - ['x-netacea-captcha'] = netacea.captchaStates.PASS - }, - status = 200 - }, nil) - - local mit = netacea.idTypes.IP .. netacea.mitigationTypes.BLOCKED .. netacea.captchaStates.PASS - ngx.var.cookie__mitata = build_mitata_cookie(ngx.time() + 20, generate_uid(), mit, mit_svc_secret) - - package.loaded['lua_resty_netacea'] = nil - - netacea = (require 'lua_resty_netacea'):new({ - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local logFunc = spy.new(function(res) - assert.are.equal(res.idType, netacea.idTypes.IP) - assert.are.equal(res.mitigationType, netacea.mitigationTypes.BLOCKED) - assert.are.equal(res.captchaState, netacea.captchaStates.COOKIEPASS) - end) - - netacea:run(logFunc) - - assert.spy(logFunc).was.called() - end) - - it('forwards non-captcha attempt captcha FAIL to mit service and expects COOKIEFAIL response', function() - local netacea = (require 'lua_resty_netacea') - setHttpResponse(mit_svc_url, { - headers = { - ['x-netacea-match'] = netacea.idTypes.IP, - ['x-netacea-mitigate'] = netacea.mitigationTypes.BLOCKED, - ['x-netacea-captcha'] = netacea.captchaStates.COOKIEFAIL - }, - status = 200 - }, nil) - - local mit = netacea.idTypes.IP .. netacea.mitigationTypes.BLOCKED .. netacea.captchaStates.FAIL - ngx.var.cookie__mitata = build_mitata_cookie(ngx.time() + 20, generate_uid(), mit, mit_svc_secret) - - package.loaded['lua_resty_netacea'] = nil - - netacea = (require 'lua_resty_netacea'):new({ - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local logFunc = spy.new(function(res) - assert.are.equal(res.idType, netacea.idTypes.IP) - assert.are.equal(res.mitigationType, netacea.mitigationTypes.BLOCKED) - assert.are.equal(res.captchaState, netacea.captchaStates.COOKIEFAIL) - end) - - netacea:run(logFunc) - - assert.spy(logFunc).was.called() - - end) - - it('Uses and forwards configured user id variable when verifying captcha', function() - local userIdKey = 'customUserIdValue' - local userIdVal = 'someCustomUserId' - ngx.var[userIdKey] = userIdVal - ngx.var.request_uri = 'AtaVerifyCaptcha' - - local req_spy = setHttpResponse(mit_svc_url_captcha, nil, 'error') - local netacea = (require 'lua_resty_netacea'):new({ - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - mitigationEnabled = true, - userIdKey = userIdKey, - mitigationType = 'MITIGATE' - }) - - netacea:run() - - local _ = match._ - assert.spy(req_spy).was.called_with(_, mit_svc_url_captcha, { - method = 'POST', - headers = { - ['x-netacea-api-key'] = mit_svc_api_key, - ['content-type'] = 'application/x-www-form-urlencoded', - ["cookie"] = '_mitata=' .. ngx.var.cookie__mitata .. ';_mitatacaptcha=', - ["x-netacea-userid"] = userIdVal, - ['user-agent'] = ngx.var.http_user_agent, - ['x-netacea-client-ip'] = ngx.var.remote_addr - } - }) - end) - - it('Works with a single mitigation service endpoint', function() - local req_spy = setHttpResponse('mitigation.endpoint', nil, 'error') - local netacea = (require 'lua_resty_netacea'):new(netacea_default_params) - - netacea:run() - - assert.spy(req_spy).was.called(1) - assert.spy(req_spy).was.called_with(match._, 'mitigation.endpoint', match.is_table()) - end) - - it('Round Robins between multiple mitigation service endpoints', function() - local endpointArray = { 'mitigation.endpoint', 'mitigation2.endpoint', 'mitigation3.endpoint' } - local netacea = (require 'lua_resty_netacea'):new( - copy_table( - netacea_default_params, - { mitigationEndpoint = endpointArray } - ) - ) - - local req_spy = setHttpResponse(nil, nil, 'error') - netacea:run() -- request 1 - endpoint 2 - assert.spy(req_spy).was.called(1) - assert.spy(req_spy).was.called_with(match._, endpointArray[2], match.is_table()) - assert.spy(req_spy).was_not.called_with(match._, endpointArray[3], match.is_table()) - assert.spy(req_spy).was_not.called_with(match._, endpointArray[1], match.is_table()) - netacea:run() -- request 2 - endpoint 3 - assert.spy(req_spy).was.called(2) - assert.spy(req_spy).was.called_with(match._, endpointArray[3], match.is_table()) - assert.spy(req_spy).was_not.called_with(match._, endpointArray[1], match.is_table()) - netacea:run() -- request 3 - endpoint 1 - assert.spy(req_spy).was.called(3) - assert.spy(req_spy).was.called_with(match._, endpointArray[1], match.is_table()) - req_spy = setHttpResponse(nil, nil, 'error') -- reset call history for spy - netacea:run() -- request 4 - endpoint 2 - assert.spy(req_spy).was.called(1) - assert.spy(req_spy).was.called_with(match._, endpointArray[2], match.is_table()) - assert.spy(req_spy).was_not.called_with(match._, endpointArray[3], match.is_table()) - assert.spy(req_spy).was_not.called_with(match._, endpointArray[1], match.is_table()) - netacea:run() -- request 5 - endpoint 3 - assert.spy(req_spy).was.called(2) - assert.spy(req_spy).was.called_with(match._, endpointArray[3], match.is_table()) - assert.spy(req_spy).was_not.called_with(match._, endpointArray[1], match.is_table()) - netacea:run() -- request 6 - endpoint 1 - assert.spy(req_spy).was.called(3) - assert.spy(req_spy).was.called_with(match._, endpointArray[1], match.is_table()) - end) - - it('Passes idTypes even if idType is not found in idTypes dict', function() - local netacea = (require 'lua_resty_netacea') - setHttpResponse(mit_svc_url, { - headers = { - ['x-netacea-match'] = 'q', - ['x-netacea-mitigate'] = netacea.mitigationTypes.BLOCKED, - ['x-netacea-captcha'] = netacea.captchaStates.NONE - }, - status = 200 - }, nil) - - package.loaded['lua_resty_netacea'] = nil - - netacea = (require 'lua_resty_netacea'):new({ - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local logFunc = spy.new(function(res) - assert.are.equal(res.idType, 'q') - assert.are.equal(res.mitigationType, netacea.mitigationTypes.BLOCKED) - assert.are.equal(res.captchaState, netacea.captchaStates.NONE) - end) - - netacea:run(logFunc) - - assert.spy(logFunc).was.called() - end) - - it('Passes mitigationType even if mitigationType is not found in mitigationTypes dict', function() - local netacea = (require 'lua_resty_netacea') - setHttpResponse(mit_svc_url, { - headers = { - ['x-netacea-match'] = netacea.idTypes.IP, - ['x-netacea-mitigate'] = 'q', - ['x-netacea-captcha'] = netacea.captchaStates.NONE - }, - status = 200 - }, nil) - - package.loaded['lua_resty_netacea'] = nil - - netacea = (require 'lua_resty_netacea'):new({ - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local logFunc = spy.new(function(res) - assert.are.equal(res.idType, netacea.idTypes.IP) - assert.are.equal(res.mitigationType, 'q') - assert.are.equal(res.captchaState, netacea.captchaStates.NONE) - end) - - netacea:run(logFunc) - - assert.spy(logFunc).was.called() - end) - - it('Passes catchaState even if captchaState is not found in captchaStates dict', function() - local netacea = (require 'lua_resty_netacea') - setHttpResponse(mit_svc_url, { - headers = { - ['x-netacea-match'] = netacea.idTypes.IP, - ['x-netacea-mitigate'] = netacea.mitigationTypes.BLOCKED, - ['x-netacea-captcha'] = 'q' - }, - status = 200 - }, nil) - - package.loaded['lua_resty_netacea'] = nil - - netacea = (require 'lua_resty_netacea'):new({ - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local logFunc = spy.new(function(res) - assert.are.equal(res.idType, netacea.idTypes.IP) - assert.are.equal(res.mitigationType, netacea.mitigationTypes.BLOCKED) - assert.are.equal(res.captchaState, 'q') - end) - - netacea:run(logFunc) - - assert.spy(logFunc).was.called() - end) - end) - - describe('inject', function() - local luaMatch = require('luassert.match') - local mit_svc_url = 'someurl' - local mit_svc_api_key = 'somekey' - local mit_svc_secret = 'somesecret' - local ngx = nil - - local function stubNgx() - local ngx_stub = {} - - ngx_stub.var = { - http_user_agent = 'some_user_agent', - remote_addr = 'some_remote_addr', - cookie__mitata = 'some_mitata_cookie', - request_uri = '-' - } - - ngx_stub.req = { - read_body = function() return nil end, - get_body_data = function() return nil end - } - - ngx_stub.header = {} - ngx_stub.req = { - set_header = spy.new(function(_, _) return nil end) - } - ngx_stub.status = 0 - ngx_stub.HTTP_FORBIDDEN = 402 - - ngx_stub.exit = spy.new(function(_, _) return nil end) - ngx_stub.print = spy.new(function(_, _) return nil end) - ngx_stub.ctx = { - } - ngx = wrap_table(require 'ngx', ngx_stub) - package.loaded['ngx'] = ngx - end - - before_each(function() - package.loaded['lua_resty_netacea'] = nil - - stubNgx() - end) - - it('forwards to mit svc if mitata cookie check fails', function() - local req_spy = setHttpResponse(mit_svc_url, nil, 'error') - - local netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'INJECT' - }) - - netacea.get_mitata_cookie = spy.new(function () return nil end) - - netacea:run() - - local _ = luaMatch._ - - assert.spy(req_spy).was.called(1) - assert.spy(req_spy).was.called_with(_, mit_svc_url, { - method = 'GET', - headers = { - ['x-netacea-api-key'] = mit_svc_api_key, - ['content-type'] = 'application/x-www-form-urlencoded', - ['user-agent'] = ngx.var.http_user_agent, - ['x-netacea-client-ip'] = ngx.var.remote_addr, - ["cookie"] = "_mitata=" .. ngx.var.cookie__mitata .. ';_mitatacaptcha=' - } - }) - end) - - it('does not forward to mit svc if mitata cookie is valid', function() - local req_spy = setHttpResponse('-', nil, 'error') - - local netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'INJECT' - }) - - local cookie = { - mitigation = "000" - } - - netacea.get_mitata_cookie = spy.new(function () return cookie end) - - netacea:run() - - assert.spy(req_spy).was.not_called() - end) - - it('does not forward to mit service if mitata cookie is ALLOW', function() - local req_spy = setHttpResponse('-', nil, 'error') - - local netacea = require 'lua_resty_netacea' - local match = netacea.idTypes.IP - local mitigate = netacea.mitigationTypes.ALLOW - local captcha = netacea.captchaStates.NONE - local mit = match .. mitigate .. captcha - ngx.var.cookie__mitata = build_mitata_cookie(ngx.time() + 20, generate_uid(), mit, mit_svc_secret) - - package.loaded['lua_resty_netacea'] = nil - netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'INJECT' - }) - - local logFunc = spy.new(function(res) - assert.equal(netacea.idTypes.IP, res.idType) - assert.equal(netacea.mitigationTypes.ALLOW, res.mitigationType) - assert.equal(netacea.captchaStates.NONE, res.captchaState) - end) - - netacea:run(logFunc) - assert(ngx.status == ngx.OK) - assert.spy(ngx.req.set_header).was.called_with('x-netacea-match', match) - assert.spy(ngx.req.set_header).was.called_with('x-netacea-mitigate', mitigate) - assert.spy(ngx.req.set_header).was.called_with('x-netacea-captcha', captcha) - assert.spy(req_spy).was.not_called() - assert.spy(logFunc).was.called() - end) - - it('does not forward to mit service if mitata cookie is BLOCK', function() - local req_spy = setHttpResponse('-', nil, 'error') - - local netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'INJECT' - }) - local match = netacea.idTypes.IP - local mitigate = netacea.mitigationTypes.BLOCKED - local captcha = netacea.captchaStates.NONE - local mit = match .. mitigate .. captcha - ngx.var.cookie__mitata = build_mitata_cookie(ngx.time() + 20, generate_uid(), mit, mit_svc_secret) - - local logFunc = spy.new(function(res) - assert(res.idType == match) - assert(res.mitigationType == mitigate) - assert(res.captchaState == captcha) - end) - - netacea:run(logFunc) - - assert.spy(req_spy).was.not_called() - assert(ngx.status == ngx.OK) - assert.spy(ngx.req.set_header).was.called_with('x-netacea-match', match) - assert.spy(ngx.req.set_header).was.called_with('x-netacea-mitigate', mitigate) - assert.spy(ngx.req.set_header).was.called_with('x-netacea-captcha', captcha) - assert.spy(logFunc).was.called() - end) - - it('forwards to mit service if mitata cookie is CAPTCHA SERVE', function() - local expected_captcha_body = 'some captcha body' - local netacea = require 'lua_resty_netacea' - local req_spy = setHttpResponse(mit_svc_url, { - headers = { - ['x-netacea-match'] = netacea.idTypes.IP, - ['x-netacea-mitigate'] = netacea.mitigationTypes.BLOCKED, - ['x-netacea-captcha'] = netacea.captchaStates.SERVE - }, - status = 200, - body = expected_captcha_body - }, nil) - - package.loaded['lua_resty_netacea'] = nil - netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'INJECT' - }) - - local mit = netacea.idTypes.IP .. netacea.mitigationTypes.BLOCKED .. netacea.captchaStates.SERVE - ngx.var.cookie__mitata = build_mitata_cookie(ngx.time() + 20, generate_uid(), mit, mit_svc_secret) - - local logFunc = spy.new(function(res) - assert(res.idType == netacea.idTypes.IP) - assert(res.mitigationType == netacea.mitigationTypes.BLOCKED) - assert(res.captchaState == netacea.captchaStates.SERVE) - end) - - netacea:run(logFunc) - - assert.spy(req_spy).was.called() - assert(ngx.status == ngx.OK) - assert.spy(ngx.print).was.not_called() - assert.spy(ngx.exit).was.not_called() - assert.spy(logFunc).was.called() - end) - end) - describe('ingest only mode', function() - local luaMatch = require('luassert.match') - local mit_svc_url = 'someurl' - local mit_svc_api_key = 'somekey' - local mit_svc_secret = 'somesecret' - local ngx = nil - - local function stubNgx() - local ngx_stub = {} - - ngx_stub.var = { - http_user_agent = 'some_user_agent', - remote_addr = 'some_remote_addr', - cookie__mitata = '', - request_uri = '-' - } - - ngx_stub.req = { - read_body = function() return nil end, - get_body_data = function() return nil end, - set_header = spy.new(function(_, _) return nil end) - } - - ngx_stub.header = {} - ngx_stub.status = 0 - ngx_stub.HTTP_FORBIDDEN = 402 - ngx_stub.OK = 200 - - ngx_stub.exit = spy.new(function(_, _) return nil end) - ngx_stub.print = spy.new(function(_, _) return nil end) - ngx_stub.time = spy.new(function() return os.time() end) - ngx_stub.cookie_time = spy.new(function(time) return os.date("!%a, %d %b %Y %H:%M:%S GMT", time) end) - ngx_stub.ctx = { - } - ngx = wrap_table(require 'ngx', ngx_stub) - package.loaded['ngx'] = ngx - end - - before_each(function() - package.loaded['lua_resty_netacea'] = nil - - stubNgx() - end) - - it('Sets a cookie if one does not yet exist', function() - -- Clear any existing cookie - ngx.var.cookie__mitata = '' - - local netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = 'https://fakedomain.com', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = true, - mitigationEnabled = false, - mitigationType = 'INJECT' - }) - - netacea:run(nil) - - -- Verify that Set-Cookie header was called - assert.is_not_nil(ngx.header['Set-Cookie']) - - -- Verify cookie format matches expected pattern - local cookieString = ngx.header['Set-Cookie'][1] - assert.is_string(cookieString) - assert.is_string(cookieString:match('_mitata=.*; Path=/; Expires=.*')) - - -- Verify ngx.ctx.mitata was set - assert.is_not_nil(ngx.ctx.mitata) - assert.is_string(ngx.ctx.mitata) - - -- Verify the cookie value structure (hash_/@#/_epoch_/@#/_uid_/@#/_mitigation) - local mitataValue = ngx.ctx.mitata - local parts = {} - for part in mitataValue:gmatch('([^' .. COOKIE_DELIMITER .. ']+)') do - table.insert(parts, part) - end - - assert.is_equal(4, #parts, 'Cookie should have 4 parts separated by delimiters') - assert.is_not_nil(parts[1], 'Hash should be present') - assert.is_not_nil(parts[2], 'Epoch should be present') - assert.is_not_nil(parts[3], 'UID should be present') - assert.is_not_nil(parts[4], 'Mitigation values should be present') - - -- Verify epoch is in the future (should be current time + 1 hour) - local epoch = tonumber(parts[2]) - assert.is_number(epoch) - assert.is_true(epoch > ngx.time(), 'Cookie expiration should be in the future') - assert.is_true(epoch <= ngx.time() + 3600, 'Cookie expiration should be approximately 1 hour from now') - - -- Verify UID is not empty - assert.is_true(#parts[3] > 0, 'UID should not be empty') - - -- Verify mitigation values are default (000 for ingest-only mode) - local netacea_mod = require 'lua_resty_netacea' - local expected_mitigation = netacea_mod.idTypes.NONE .. netacea_mod.mitigationTypes.NONE .. netacea_mod.captchaStates.NONE - assert.is_equal(expected_mitigation, parts[4], 'Mitigation values should be default for ingest-only mode') - end) - - it('Refreshes an existing valid cookie when expired', function() - local current_time = ngx.time() - local expired_time = current_time - 10 -- 10 seconds ago - local original_uid = generate_uid() - - -- Set up an expired but otherwise valid cookie - ngx.var.cookie__mitata = build_mitata_cookie(expired_time, original_uid, '000', mit_svc_secret) - - local netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = 'https://fakedomain.com', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = true, - mitigationEnabled = false, - mitigationType = 'INJECT' - }) - - netacea:run(nil) - - -- Verify that Set-Cookie header was set for refresh - assert.is_not_nil(ngx.header['Set-Cookie']) - assert.is_not_nil(ngx.ctx.mitata) - - -- Verify the refreshed cookie preserves the UID - local mitataValue = ngx.ctx.mitata - local parts = {} - for part in mitataValue:gmatch('([^' .. COOKIE_DELIMITER .. ']+)') do - table.insert(parts, part) - end - - assert.is_equal(original_uid, parts[3], 'UID should be preserved when refreshing expired cookie') - - -- Verify new epoch is in the future - local new_epoch = tonumber(parts[2]) - assert.is_true(new_epoch > current_time, 'Refreshed cookie should have future expiration') - end) - - it('Sets new cookie if existing cookie has invalid hash', function() - -- Create a cookie with invalid hash - local current_time = ngx.time() - local future_time = current_time + 3600 - local invalid_cookie = 'invalid_hash' .. COOKIE_DELIMITER .. future_time .. COOKIE_DELIMITER .. generate_uid() .. COOKIE_DELIMITER .. '000' - - ngx.var.cookie__mitata = invalid_cookie - - local netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = 'https://fakedomain.com', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = true, - mitigationEnabled = false, - mitigationType = 'INJECT' - }) - - netacea:run(nil) - - -- Verify that new cookie was set - assert.is_not_nil(ngx.header['Set-Cookie']) - assert.is_not_nil(ngx.ctx.mitata) - - -- Verify the new cookie is different from the invalid one - assert.is_not_equal(invalid_cookie, ngx.ctx.mitata) - end) - - it('Does not modify valid existing cookie', function() - local current_time = ngx.time() - local future_time = current_time + 1800 -- 30 minutes in future - local uid = generate_uid() - local valid_cookie = build_mitata_cookie(future_time, uid, '000', mit_svc_secret) - - ngx.var.cookie__mitata = valid_cookie - - local netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = 'https://fakedomain.com', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = true, - mitigationEnabled = false, - mitigationType = 'INJECT' - }) - - netacea:run(nil) - - -- Valid cookie should not trigger new Set-Cookie header - assert.is_nil(ngx.header['Set-Cookie']) - assert.is_nil(ngx.ctx.mitata) - end) - end) -end) diff --git a/test/lua_resty_netacea_cookies_v3.test.lua b/test/lua_resty_netacea_cookies_v3.test.lua deleted file mode 100644 index 103b88c..0000000 --- a/test/lua_resty_netacea_cookies_v3.test.lua +++ /dev/null @@ -1,380 +0,0 @@ -require 'busted.runner'() - -package.path = "../src/?.lua;" .. package.path -local match = require("luassert.match") - -describe("lua_resty_netacea_cookies_v3", function() - local NetaceaCookies - local jwt_mock - local ngx_mock - local constants_mock - - before_each(function() - -- Mock jwt module - jwt_mock = { - sign = spy.new(function(self, secretKey, payload) - return "mock_jwt_token_" .. secretKey - end), - verify = spy.new(function(self, secretKey, token) - if token == "valid_token" then - return { - verified = true, - payload = "cip=192.168.1.1&uid=user123&cid=cookie123&isr=no_session&ist=1640995200&grp=3600&mat=0&mit=0&cap=0&fCAPR=0" - } - elseif token == "expired_token" then - return { - verified = true, - payload = "cip=192.168.1.1&uid=user123&cid=cookie123&isr=no_session&ist=1000000000&grp=3600&mat=0&mit=0&cap=0&fCAPR=0" - } - elseif token == "ip_mismatch_token" then - return { - verified = true, - payload = "cip=10.0.0.1&uid=user123&cid=cookie123&isr=no_session&ist=1640995200&grp=3600&mat=0&mit=0&cap=0&fCAPR=0" - } - elseif token == "missing_fields_token" then - return { - verified = true, - payload = "cip=192.168.1.1&uid=user123" - } - elseif token == "invalid_payload_token" then - return { - verified = true, - payload = "invalid_payload_format" - } - else - return { - verified = false - } - end - end) - } - - -- Mock ngx module - ngx_mock = { - ctx = { - cookies = nil, - NetaceaState = { - client = "192.168.1.1" - } - }, - time = spy.new(function() return 1640995200 end), -- Fixed timestamp - cookie_time = spy.new(function(timestamp) - return "Thu, 01 Jan 2022 00:00:00 GMT" - end), - encode_args = spy.new(function(args) - local parts = {} - for k, v in pairs(args) do - table.insert(parts, k .. "=" .. tostring(v)) - end - return table.concat(parts, "&") - end), - decode_args = spy.new(function(str) - if str == "invalid_payload_format" then - return nil - end - local result = {} - for pair in str:gmatch("[^&]+") do - local key, value = pair:match("([^=]+)=([^=]*)") - if key and value then - result[key] = value - end - end - return result - end) - } - - -- Mock constants - constants_mock = { - issueReasons = { - NO_SESSION = 'no_session', - EXPIRED_SESSION = 'expired_session', - INVALID_SESSION = 'invalid_session', - IP_CHANGE = 'ip_change' - } - } - - -- Set up package mocks - package.loaded['resty.jwt'] = jwt_mock - package.loaded['ngx'] = ngx_mock - package.loaded['lua_resty_netacea_constants'] = constants_mock - - NetaceaCookies = require('lua_resty_netacea_cookies_v3') - end) - - after_each(function() - -- Clear mocks and cached modules - package.loaded['lua_resty_netacea_cookies_v3'] = nil - package.loaded['resty.jwt'] = nil - package.loaded['ngx'] = nil - package.loaded['lua_resty_netacea_constants'] = nil - - -- Reset ngx context - ngx_mock.ctx.cookies = nil - end) - - describe("createSetCookieValues", function() - it("should create a properly formatted cookie string", function() - local result = NetaceaCookies.createSetCookieValues("test_cookie", "test_value", 3600) - - assert.is.table(result) - assert.is.equal(1, #result) - assert.is.equal("test_cookie=test_value; Path=/; Expires=Thu, 01 Jan 2022 00:00:00 GMT", result[1]) - - assert.spy(ngx_mock.time).was.called() - assert.spy(ngx_mock.cookie_time).was.called_with(1640995200 + 3600) - end) - - it("should store cookie in ngx.ctx.cookies", function() - NetaceaCookies.createSetCookieValues("test_cookie", "test_value", 3600) - - assert.is.table(ngx_mock.ctx.cookies) - assert.is.equal("test_cookie=test_value; Path=/; Expires=Thu, 01 Jan 2022 00:00:00 GMT", - ngx_mock.ctx.cookies["test_cookie"]) - end) - - it("should handle multiple cookies", function() - NetaceaCookies.createSetCookieValues("cookie1", "value1", 3600) - local result = NetaceaCookies.createSetCookieValues("cookie2", "value2", 7200) - - assert.is.equal(2, #result) - assert.is.table(ngx_mock.ctx.cookies) - assert.is.truthy(ngx_mock.ctx.cookies["cookie1"]) - assert.is.truthy(ngx_mock.ctx.cookies["cookie2"]) - end) - - it("should handle existing cookies in context", function() - ngx_mock.ctx.cookies = { - existing_cookie = "existing_cookie=existing_value; Path=/; Expires=Thu, 01 Jan 2022 00:00:00 GMT" - } - - local result = NetaceaCookies.createSetCookieValues("new_cookie", "new_value", 3600) - - assert.is.equal(2, #result) - end) - - it("should handle zero expiry time", function() - local result = NetaceaCookies.createSetCookieValues("test_cookie", "test_value", 0) - - assert.is.table(result) - assert.is.equal(1, #result) - assert.spy(ngx_mock.cookie_time).was.called_with(1640995200) - end) - - it("should convert expiry to number", function() - local result = NetaceaCookies.createSetCookieValues("test_cookie", "test_value", "3600") - - assert.is.table(result) - assert.spy(ngx_mock.cookie_time).was.called_with(1640995200 + 3600) - end) - end) - - describe("generateNewCookieValue", function() - it("should generate a new cookie value with all parameters", function() - local _ = match._ - local settings = { fCAPR = 1 } - local result = NetaceaCookies.generateNewCookieValue( - "secret_key", - "192.168.1.1", - "user123", - "cookie123", - "no_session", - 1640995200, - 3600, - 1, - 2, - 3, - settings - ) - - assert.is.table(result) - assert.is.string(result.mitata_jwe) - assert.is.string(result.mitata_plaintext) - assert.is.equal("mock_jwt_token_secret_key", result.mitata_jwe) - - assert.spy(ngx_mock.encode_args).was.called() - assert.spy(jwt_mock.sign).was.called_with(match.is_not_nil(), "secret_key", { - header = { typ="JWE", alg="dir", enc="A128CBC-HS256" }, - payload = "ist=1640995200&mit=2&isr=no_session&cip=192.168.1.1&grp=3600&uid=user123&fCAPR=1&cid=cookie123&cap=3&mat=1" - }) - end) - - it("should use default values for optional parameters", function() - local settings = {} - local result = NetaceaCookies.generateNewCookieValue( - "secret_key", - "192.168.1.1", - "user123", - "cookie123", - "no_session", - 1640995200, - 3600, - nil, -- match - nil, -- mitigation - nil, -- captcha - settings - ) - - assert.is.table(result) - assert.spy(ngx_mock.encode_args).was.called_with({ - cip = "192.168.1.1", - uid = "user123", - cid = "cookie123", - isr = "no_session", - ist = 1640995200, - grp = 3600, - mat = 0, - mit = 0, - cap = 0, - fCAPR = 0 - }) - end) - - it("should handle empty settings", function() - local result = NetaceaCookies.generateNewCookieValue( - "secret_key", - "192.168.1.1", - "user123", - "cookie123", - "no_session", - 1640995200, - 3600, - 1, - 2, - 3, - {} - ) - - assert.is.table(result) - assert.is.string(result.mitata_jwe) - assert.is.string(result.mitata_plaintext) - end) - end) - - describe("parseMitataCookie", function() - it("should return invalid result for nil cookie", function() - local result = NetaceaCookies.parseMitataCookie(nil, "secret_key") - - assert.is.table(result) - assert.is_false(result.valid) - assert.is.equal('no_session', result.reason) - end) - - it("should return invalid result for empty cookie", function() - local result = NetaceaCookies.parseMitataCookie("", "secret_key") - - assert.is.table(result) - assert.is_false(result.valid) - assert.is.equal('no_session', result.reason) - end) - - it("should return invalid result for unverified JWT", function() - local _ = match._ - local result = NetaceaCookies.parseMitataCookie("invalid_token", "secret_key") - - assert.is.table(result) - assert.is_false(result.valid) - assert.is.equal('invalid_session', result.reason) - assert.spy(jwt_mock.verify).was.called_with(match.is_not_nil(), "secret_key", "invalid_token") - end) - - it("should return invalid result for invalid payload format", function() - local result = NetaceaCookies.parseMitataCookie("invalid_payload_token", "secret_key") - - assert.is.table(result) - assert.is_false(result.valid) - assert.is.equal('invalid_session', result.reason) - end) - - it("should return invalid result for missing required fields", function() - local result = NetaceaCookies.parseMitataCookie("missing_fields_token", "secret_key") - - assert.is.table(result) - assert.is_false(result.valid) - assert.is.equal('invalid_session', result.reason) - end) - - it("should return invalid result for expired cookie", function() - local result = NetaceaCookies.parseMitataCookie("expired_token", "secret_key") - - assert.is.table(result) - assert.is_false(result.valid) - assert.is.equal('expired_session', result.reason) - assert.is.equal('user123', result.user_id) - end) - - it("should return invalid result for IP mismatch", function() - local result = NetaceaCookies.parseMitataCookie("ip_mismatch_token", "secret_key") - - assert.is.table(result) - assert.is_false(result.valid) - assert.is.equal('ip_change', result.reason) - assert.is.equal('user123', result.user_id) - end) - - it("should return valid result for valid cookie", function() - local result = NetaceaCookies.parseMitataCookie("valid_token", "secret_key") - - assert.is.table(result) - assert.is_true(result.valid) - assert.is.equal('user123', result.user_id) - assert.is.table(result.data) - assert.is.equal('192.168.1.1', result.data.cip) - assert.is.equal('user123', result.data.uid) - assert.is.equal('cookie123', result.data.cid) - end) - - it("should call jwt.verify with correct parameters", function() - NetaceaCookies.parseMitataCookie("test_cookie", "test_secret") - - assert.spy(jwt_mock.verify).was.called_with(match.is_not_nil(), "test_secret", "test_cookie") - end) - - it("should check all required fields", function() - -- This test ensures all required fields are checked - local required_fields = {'cip', 'uid', 'cid', 'isr', 'ist', 'grp', 'mat', 'mit', 'cap', 'fCAPR'} - - -- Create a mock that returns a payload missing each field one at a time - for _, missing_field in ipairs(required_fields) do - jwt_mock.verify = spy.new(function(secretKey, token) - local payload_parts = {} - for _, field in ipairs(required_fields) do - if field ~= missing_field then - table.insert(payload_parts, field .. "=value") - end - end - return { - verified = true, - payload = table.concat(payload_parts, "&") - } - end) - - local result = NetaceaCookies.parseMitataCookie("test_token", "secret_key") - assert.is_false(result.valid, "Should be invalid when missing field: " .. missing_field) - assert.is.equal('invalid_session', result.reason) - end - end) - end) - describe("newUserId #only", function() - it("should generate a user ID starting with 'c' followed by 15 characters", function() - local userId = NetaceaCookies.newUserId() - - assert.is_string(userId) - assert.is.equal(16, #userId) - assert.is.equal('c', userId:sub(1,1)) - end) - - it("should generate different user IDs on multiple calls", function() - local userId1 = NetaceaCookies.newUserId() - local userId2 = NetaceaCookies.newUserId() - - assert.is_not.equal(userId1, userId2) - end) - - it("should generate user ID with alphanumeric characters", function() - local userId = NetaceaCookies.newUserId() - local pattern = "^c[%w_%-]+$" -- Alphanumeric, underscore, hyphen - - assert.is_true(userId:match(pattern) ~= nil, "User ID should match pattern: " .. pattern) - end) - end) -end) diff --git a/test/lua_resty_netacea_cookies_v3_spec.lua b/test/lua_resty_netacea_cookies_v3_spec.lua new file mode 100644 index 0000000..644cdc6 --- /dev/null +++ b/test/lua_resty_netacea_cookies_v3_spec.lua @@ -0,0 +1,920 @@ +require("silence_g_write_guard") +require 'busted.runner'() + +package.path = "../src/?.lua;" .. package.path +local match = require("luassert.match") + +describe("lua_resty_netacea_cookies_v3", function() + local NetaceaCookies + local jwt_mock + local ngx_mock + local constants_mock + + before_each(function() + -- Mock jwt module + jwt_mock = { + sign = spy.new(function(self, secretKey, payload) + return "mock_jwt_token_" .. secretKey + end), + verify = spy.new(function(self, secretKey, token) + if token == "valid_token" then + return { + verified = true, + payload = "cip=192.168.1.1&uid=user123&cid=cookie123&isr=no_session&ist=1640995200&grp=3600&mat=0&mit=0&cap=0&fCAPR=0" + } + elseif token == "expired_token" then + return { + verified = true, + payload = "cip=192.168.1.1&uid=user123&cid=cookie123&isr=no_session&ist=1000000000&grp=3600&mat=0&mit=0&cap=0&fCAPR=0" + } + elseif token == "ip_mismatch_token" then + return { + verified = true, + payload = "cip=10.0.0.1&uid=user123&cid=cookie123&isr=no_session&ist=1640995200&grp=3600&mat=0&mit=0&cap=0&fCAPR=0" + } + elseif token == "missing_fields_token" then + return { + verified = true, + payload = "cip=192.168.1.1&uid=user123" + } + elseif token == "invalid_payload_token" then + return { + verified = true, + payload = "invalid_payload_format" + } + else + return { + verified = false + } + end + end) + } + + -- Mock ngx module + ngx_mock = { + ctx = { + cookies = nil, + NetaceaState = { + client = "192.168.1.1" + } + }, + time = spy.new(function() return 1640995200 end), -- Fixed timestamp + cookie_time = spy.new(function(timestamp) + return "Thu, 01 Jan 2022 00:00:00 GMT" + end), + encode_args = spy.new(function(args) + local parts = {} + for k, v in pairs(args) do + table.insert(parts, k .. "=" .. tostring(v)) + end + return table.concat(parts, "&") + end), + decode_args = spy.new(function(str) + if str == "invalid_payload_format" then + return nil + end + if not str then + return nil + end + local result = {} + for pair in str:gmatch("[^&]+") do + local key, value = pair:match("([^=]+)=([^=]*)") + if key and value then + result[key] = value + end + end + return result + end) + } + + -- Import actual constants + local constants = require('lua_resty_netacea_constants') + + -- Set up package mocks + package.loaded['resty.jwt'] = jwt_mock + package.loaded['ngx'] = ngx_mock + package.loaded['lua_resty_netacea_constants'] = constants + + NetaceaCookies = require('lua_resty_netacea_cookies_v3') + end) + + after_each(function() + -- Clear mocks and cached modules + package.loaded['lua_resty_netacea_cookies_v3'] = nil + package.loaded['resty.jwt'] = nil + package.loaded['ngx'] = nil + package.loaded['lua_resty_netacea_constants'] = nil + + -- Reset ngx context + ngx_mock.ctx.cookies = nil + end) + + describe("createSetCookieValues", function() + it("should create a properly formatted cookie string", function() + local result = NetaceaCookies.createSetCookieValues("test_cookie", "test_value", 3600) + + assert.is.table(result) + assert.is.equal(1, #result) + assert.is.equal("test_cookie=test_value; Path=/; Expires=Thu, 01 Jan 2022 00:00:00 GMT", result[1]) + + assert.spy(ngx_mock.time).was.called() + assert.spy(ngx_mock.cookie_time).was.called_with(1640995200 + 3600) + end) + + it("should store cookie in ngx.ctx.cookies", function() + NetaceaCookies.createSetCookieValues("test_cookie", "test_value", 3600) + + assert.is.table(ngx_mock.ctx.cookies) + assert.is.equal("test_cookie=test_value; Path=/; Expires=Thu, 01 Jan 2022 00:00:00 GMT", + ngx_mock.ctx.cookies["test_cookie"]) + end) + + it("should handle multiple cookies", function() + NetaceaCookies.createSetCookieValues("cookie1", "value1", 3600) + local result = NetaceaCookies.createSetCookieValues("cookie2", "value2", 7200) + + assert.is.equal(2, #result) + assert.is.table(ngx_mock.ctx.cookies) + assert.is.truthy(ngx_mock.ctx.cookies["cookie1"]) + assert.is.truthy(ngx_mock.ctx.cookies["cookie2"]) + end) + + it("should handle existing cookies in context", function() + ngx_mock.ctx.cookies = { + existing_cookie = "existing_cookie=existing_value; Path=/; Expires=Thu, 01 Jan 2022 00:00:00 GMT" + } + + local result = NetaceaCookies.createSetCookieValues("new_cookie", "new_value", 3600) + + assert.is.equal(2, #result) + end) + + it("should handle zero expiry time", function() + local result = NetaceaCookies.createSetCookieValues("test_cookie", "test_value", 0) + + assert.is.table(result) + assert.is.equal(1, #result) + assert.spy(ngx_mock.cookie_time).was.called_with(1640995200) + end) + + it("should convert expiry to number", function() + local result = NetaceaCookies.createSetCookieValues("test_cookie", "test_value", "3600") + + assert.is.table(result) + assert.spy(ngx_mock.cookie_time).was.called_with(1640995200 + 3600) + end) + + it("should handle negative expiry time", function() + local result = NetaceaCookies.createSetCookieValues("test_cookie", "test_value", -3600) + + assert.is.table(result) + assert.is.equal(1, #result) + assert.spy(ngx_mock.cookie_time).was.called_with(1640995200 - 3600) + end) + + it("should handle very large expiry time", function() + local result = NetaceaCookies.createSetCookieValues("test_cookie", "test_value", 31536000) -- 1 year + + assert.is.table(result) + assert.is.equal(1, #result) + assert.spy(ngx_mock.cookie_time).was.called_with(1640995200 + 31536000) + end) + + it("should handle float expiry time", function() + local result = NetaceaCookies.createSetCookieValues("test_cookie", "test_value", 3600.5) + + assert.is.table(result) + assert.spy(ngx_mock.cookie_time).was.called_with(1640995200 + 3600.5) + end) + + it("should handle special characters in cookie name and value", function() + local result = NetaceaCookies.createSetCookieValues("test-cookie_123", "value!@#$%^&*()", 3600) + + assert.is.table(result) + assert.is.equal(1, #result) + assert.is.equal("test-cookie_123=value!@#$%^&*(); Path=/; Expires=Thu, 01 Jan 2022 00:00:00 GMT", result[1]) + end) + + it("should handle empty cookie name", function() + local result = NetaceaCookies.createSetCookieValues("", "test_value", 3600) + + assert.is.table(result) + assert.is.equal(1, #result) + assert.is.equal("=test_value; Path=/; Expires=Thu, 01 Jan 2022 00:00:00 GMT", result[1]) + end) + + it("should handle empty cookie value", function() + local result = NetaceaCookies.createSetCookieValues("test_cookie", "", 3600) + + assert.is.table(result) + assert.is.equal(1, #result) + assert.is.equal("test_cookie=; Path=/; Expires=Thu, 01 Jan 2022 00:00:00 GMT", result[1]) + end) + + it("should handle cookie replacement with same name", function() + -- First create a cookie + NetaceaCookies.createSetCookieValues("same_cookie", "value1", 3600) + + -- Then replace it with the same name + local result = NetaceaCookies.createSetCookieValues("same_cookie", "value2", 7200) + + assert.is.table(result) + assert.is.equal(1, #result) -- Should still be only 1 cookie + assert.is.equal("same_cookie=value2; Path=/; Expires=Thu, 01 Jan 2022 00:00:00 GMT", result[1]) + + -- Check that the context was updated + assert.is.equal("same_cookie=value2; Path=/; Expires=Thu, 01 Jan 2022 00:00:00 GMT", + ngx_mock.ctx.cookies["same_cookie"]) + end) + + it("should maintain consistent cookie format", function() + local result = NetaceaCookies.createSetCookieValues("format_test", "format_value", 1800) + + assert.is.table(result) + assert.is.equal(1, #result) + + local cookie_string = result[1] + -- Check that it follows the expected format: name=value; Path=/; Expires=... + assert.is_true(cookie_string:match("^[^=]+=[^;]*; Path=/; Expires=.+$") ~= nil, + "Cookie should match expected format") + end) + + it("should handle complex cookie values with spaces and special chars", function() + local complex_value = "user data with spaces & special chars = test" + local result = NetaceaCookies.createSetCookieValues("complex_cookie", complex_value, 3600) + + assert.is.table(result) + assert.is.equal(1, #result) + assert.is.equal("complex_cookie=" .. complex_value .. "; Path=/; Expires=Thu, 01 Jan 2022 00:00:00 GMT", result[1]) + end) + + it("should call time functions in correct order", function() + NetaceaCookies.createSetCookieValues("timing_test", "test_value", 3600) + + -- ngx.time() should be called before ngx.cookie_time() + assert.spy(ngx_mock.time).was.called() + assert.spy(ngx_mock.cookie_time).was.called() + + -- Verify the time calculation + assert.spy(ngx_mock.cookie_time).was.called_with(1640995200 + 3600) + end) + end) + + describe("generateNewCookieValue", function() + it("should generate a new cookie value with all parameters", function() + local _ = match._ + local settings = { fCAPR = 1 } + local result = NetaceaCookies.generateNewCookieValue( + "secret_key", + "192.168.1.1", + "user123", + "cookie123", + "no_session", + 1640995200, + 3600, + 1, + 2, + 3, + settings + ) + + assert.is.table(result) + assert.is.string(result.mitata_jwe) + assert.is.string(result.mitata_plaintext) + assert.is.equal("mock_jwt_token_secret_key", result.mitata_jwe) + + assert.spy(ngx_mock.encode_args).was.called() + -- Note: ngx.encode_args output order may vary, so we just check that sign was called + assert.spy(jwt_mock.sign).was.called() + local call_args = jwt_mock.sign.calls[1].vals + assert.is.equal("secret_key", call_args[2]) + assert.is.table(call_args[3]) + assert.is.table(call_args[3].header) + assert.is.string(call_args[3].payload) + end) + + it("should use default values for optional parameters", function() + local settings = {} + local result = NetaceaCookies.generateNewCookieValue( + "secret_key", + "192.168.1.1", + "user123", + "cookie123", + "no_session", + 1640995200, + 3600, + nil, -- match + nil, -- mitigation + nil, -- captcha + settings + ) + + assert.is.table(result) + assert.spy(ngx_mock.encode_args).was.called_with({ + cip = "192.168.1.1", + uid = "user123", + cid = "cookie123", + isr = "no_session", + ist = 1640995200, + grp = 3600, + mat = 0, + mit = 0, + cap = 0, + fCAPR = 0 + }) + end) + + it("should handle empty settings", function() + local result = NetaceaCookies.generateNewCookieValue( + "secret_key", + "192.168.1.1", + "user123", + "cookie123", + "no_session", + 1640995200, + 3600, + 1, + 2, + 3, + {} + ) + + assert.is.table(result) + assert.is.string(result.mitata_jwe) + assert.is.string(result.mitata_plaintext) + end) + + it("should handle nil settings", function() + local result = NetaceaCookies.generateNewCookieValue( + "secret_key", + "192.168.1.1", + "user123", + "cookie123", + "no_session", + 1640995200, + 3600, + 1, + 2, + 3, + nil + ) + + assert.is.table(result) + assert.is.string(result.mitata_jwe) + assert.is.string(result.mitata_plaintext) + end) + + it("should handle edge case with zero values", function() + local settings = { fCAPR = 0 } + local result = NetaceaCookies.generateNewCookieValue( + "secret_key", + "192.168.1.1", + "user123", + "cookie123", + "no_session", + 0, -- zero timestamp + 0, -- zero grace period + 0, -- zero match + 0, -- zero mitigation + 0, -- zero captcha + settings + ) + + assert.is.table(result) + assert.spy(ngx_mock.encode_args).was.called_with({ + cip = "192.168.1.1", + uid = "user123", + cid = "cookie123", + isr = "no_session", + ist = 0, + grp = 0, + mat = 0, + mit = 0, + cap = 0, + fCAPR = 0 + }) + end) + + it("should handle large numeric values", function() + local settings = { fCAPR = 999999 } + local result = NetaceaCookies.generateNewCookieValue( + "secret_key", + "192.168.1.1", + "user123", + "cookie123", + "no_session", + 2147483647, -- Max int32 + 999999, + 999999, + 999999, + 999999, + settings + ) + + assert.is.table(result) + assert.is.string(result.mitata_jwe) + assert.is.string(result.mitata_plaintext) + end) + + it("should handle empty string parameters", function() + local settings = { fCAPR = 0 } + local result = NetaceaCookies.generateNewCookieValue( + "", -- empty secret key + "", -- empty client IP + "", -- empty user ID + "", -- empty cookie ID + "", -- empty issue reason + 1640995200, + 3600, + 0, + 0, + 0, + settings + ) + + assert.is.table(result) + assert.is.string(result.mitata_jwe) + assert.is.string(result.mitata_plaintext) + end) + + it("should handle special characters in string parameters", function() + local settings = { fCAPR = 1 } + local result = NetaceaCookies.generateNewCookieValue( + "secret_key!@#$%", + "192.168.1.1", + "user@domain.com", + "cookie_id-123", + "reason with spaces", + 1640995200, + 3600, + 1, + 2, + 3, + settings + ) + + assert.is.table(result) + assert.is.string(result.mitata_jwe) + assert.is.string(result.mitata_plaintext) + assert.spy(ngx_mock.encode_args).was.called_with({ + cip = "192.168.1.1", + uid = "user@domain.com", + cid = "cookie_id-123", + isr = "reason with spaces", + ist = 1640995200, + grp = 3600, + mat = 1, + mit = 2, + cap = 3, + fCAPR = 1 + }) + end) + + it("should handle IPv6 addresses", function() + local settings = { fCAPR = 0 } + local result = NetaceaCookies.generateNewCookieValue( + "secret_key", + "2001:0db8:85a3:0000:0000:8a2e:0370:7334", -- IPv6 + "user123", + "cookie123", + "no_session", + 1640995200, + 3600, + 1, + 2, + 3, + settings + ) + + assert.is.table(result) + assert.spy(ngx_mock.encode_args).was.called_with({ + cip = "2001:0db8:85a3:0000:0000:8a2e:0370:7334", + uid = "user123", + cid = "cookie123", + isr = "no_session", + ist = 1640995200, + grp = 3600, + mat = 1, + mit = 2, + cap = 3, + fCAPR = 0 + }) + end) + + it("should create consistent plaintext format", function() + local settings = { fCAPR = 1 } + local result = NetaceaCookies.generateNewCookieValue( + "secret_key", + "192.168.1.1", + "user123", + "cookie123", + "no_session", + 1640995200, + 3600, + 5, + 10, + 15, + settings + ) + + -- The plaintext should contain all the encoded parameters + assert.is.string(result.mitata_plaintext) + local plaintext = result.mitata_plaintext + + -- Check that all expected parameters are present in the plaintext + assert.is_true(plaintext:match("cip=192%.168%.1%.1") ~= nil) + assert.is_true(plaintext:match("uid=user123") ~= nil) + assert.is_true(plaintext:match("cid=cookie123") ~= nil) + assert.is_true(plaintext:match("isr=no_session") ~= nil) + assert.is_true(plaintext:match("ist=1640995200") ~= nil) + assert.is_true(plaintext:match("grp=3600") ~= nil) + assert.is_true(plaintext:match("mat=5") ~= nil) + assert.is_true(plaintext:match("mit=10") ~= nil) + assert.is_true(plaintext:match("cap=15") ~= nil) + assert.is_true(plaintext:match("fCAPR=1") ~= nil) + end) + end) + + describe("parseMitataCookie", function() + it("should return invalid result for nil cookie", function() + local result = NetaceaCookies.parseMitataCookie(nil, "secret_key") + + assert.is.table(result) + assert.is_false(result.valid) + assert.is.equal('no_session', result.reason) + end) + + it("should return invalid result for empty cookie", function() + local result = NetaceaCookies.parseMitataCookie("", "secret_key") + + assert.is.table(result) + assert.is_false(result.valid) + assert.is.equal('no_session', result.reason) + end) + + it("should return invalid result for unverified JWT", function() + local _ = match._ + local result = NetaceaCookies.parseMitataCookie("invalid_token", "secret_key") + + assert.is.table(result) + assert.is_false(result.valid) + assert.is.equal('invalid_session', result.reason) + assert.spy(jwt_mock.verify).was.called_with(match.is_not_nil(), "secret_key", "invalid_token") + end) + + it("should return invalid result for invalid payload format", function() + local result = NetaceaCookies.parseMitataCookie("invalid_payload_token", "secret_key") + + assert.is.table(result) + assert.is_false(result.valid) + assert.is.equal('invalid_session', result.reason) + end) + + it("should return invalid result for missing required fields", function() + local result = NetaceaCookies.parseMitataCookie("missing_fields_token", "secret_key") + + assert.is.table(result) + assert.is_false(result.valid) + assert.is.equal('invalid_session', result.reason) + end) + + it("should return invalid result for expired cookie", function() + local result = NetaceaCookies.parseMitataCookie("expired_token", "secret_key") + + assert.is.table(result) + assert.is_false(result.valid) + assert.is.equal('expired_session', result.reason) + assert.is.equal('user123', result.user_id) + end) + + it("should return invalid result for IP mismatch", function() + local result = NetaceaCookies.parseMitataCookie("ip_mismatch_token", "secret_key") + + assert.is.table(result) + assert.is_false(result.valid) + assert.is.equal('ip_change', result.reason) + assert.is.equal('user123', result.user_id) + end) + + it("should return valid result for valid cookie", function() + local result = NetaceaCookies.parseMitataCookie("valid_token", "secret_key") + + assert.is.table(result) + assert.is_true(result.valid) + assert.is.equal('user123', result.user_id) + assert.is.table(result.data) + assert.is.equal('192.168.1.1', result.data.cip) + assert.is.equal('user123', result.data.uid) + assert.is.equal('cookie123', result.data.cid) + end) + + it("should call jwt.verify with correct parameters", function() + NetaceaCookies.parseMitataCookie("test_cookie", "test_secret") + + assert.spy(jwt_mock.verify).was.called_with(match.is_not_nil(), "test_secret", "test_cookie") + end) + + it("should check all required fields", function() + -- This test ensures all required fields are checked + local required_fields = {'cip', 'uid', 'cid', 'isr', 'ist', 'grp', 'mat', 'mit', 'cap', 'fCAPR'} + + -- Create a mock that returns a payload missing each field one at a time + for _, missing_field in ipairs(required_fields) do + jwt_mock.verify = spy.new(function(secretKey, token) + local payload_parts = {} + for _, field in ipairs(required_fields) do + if field ~= missing_field then + table.insert(payload_parts, field .. "=value") + end + end + return { + verified = true, + payload = table.concat(payload_parts, "&") + } + end) + + local result = NetaceaCookies.parseMitataCookie("test_token", "secret_key") + assert.is_false(result.valid, "Should be invalid when missing field: " .. missing_field) + assert.is.equal('invalid_session', result.reason) + end + end) + + it("should handle malformed payload that doesn't decode", function() + jwt_mock.verify = spy.new(function(secretKey, token) + return { + verified = true, + payload = "malformed_payload_that_fails_decode" + } + end) + + -- Mock ngx.decode_args to return nil for malformed payload + ngx_mock.decode_args = spy.new(function(str) + if str == "malformed_payload_that_fails_decode" then + return nil + end + return {} + end) + + local result = NetaceaCookies.parseMitataCookie("test_token", "secret_key") + + assert.is_false(result.valid) + assert.is.equal('invalid_session', result.reason) + end) + + it("should handle non-table result from decode_args", function() + jwt_mock.verify = spy.new(function(secretKey, token) + return { + verified = true, + payload = "test_payload" + } + end) + + -- Mock ngx.decode_args to return a string instead of table + ngx_mock.decode_args = spy.new(function(str) + return "not_a_table" + end) + + local result = NetaceaCookies.parseMitataCookie("test_token", "secret_key") + + assert.is_false(result.valid) + assert.is.equal('invalid_session', result.reason) + end) + + it("should handle edge case where timestamp is exactly at expiry", function() + -- Set up a cookie that expires exactly at the current time + jwt_mock.verify = spy.new(function(secretKey, token) + return { + verified = true, + payload = "cip=192.168.1.1&uid=user123&cid=cookie123&isr=no_session&ist=1640991599&grp=3600&mat=0&mit=0&cap=0&fCAPR=0" + } + end) + + -- Current time is 1640995200, cookie was issued at 1640991599 with 3600s grace = expires at 1640995199 (1 second before current time) + local result = NetaceaCookies.parseMitataCookie("edge_case_token", "secret_key") + + assert.is_false(result.valid) + assert.is.equal('expired_session', result.reason) + assert.is.equal('user123', result.user_id) + end) + + it("should handle future timestamps correctly", function() + jwt_mock.verify = spy.new(function(secretKey, token) + return { + verified = true, + payload = "cip=192.168.1.1&uid=user123&cid=cookie123&isr=no_session&ist=2000000000&grp=3600&mat=0&mit=0&cap=0&fCAPR=0" + } + end) + + local result = NetaceaCookies.parseMitataCookie("future_token", "secret_key") + + assert.is_true(result.valid) + assert.is.equal('user123', result.user_id) + end) + + it("should handle client IP with different formats", function() + -- Test with loopback IP + jwt_mock.verify = spy.new(function(secretKey, token) + return { + verified = true, + payload = "cip=127.0.0.1&uid=user123&cid=cookie123&isr=no_session&ist=1640995200&grp=3600&mat=0&mit=0&cap=0&fCAPR=0" + } + end) + + ngx_mock.ctx.NetaceaState.client = "127.0.0.1" + + local result = NetaceaCookies.parseMitataCookie("loopback_token", "secret_key") + + assert.is_true(result.valid) + assert.is.equal('user123', result.user_id) + end) + + it("should handle empty string fields in payload", function() + jwt_mock.verify = spy.new(function(secretKey, token) + return { + verified = true, + payload = "cip=192.168.1.1&uid=&cid=&isr=&ist=1640995200&grp=3600&mat=0&mit=0&cap=0&fCAPR=0" + } + end) + + local result = NetaceaCookies.parseMitataCookie("empty_fields_token", "secret_key") + + assert.is_true(result.valid) + assert.is.equal('', result.user_id) -- uid is empty but present + assert.is.equal('', result.data.uid) + assert.is.equal('', result.data.cid) + assert.is.equal('', result.data.isr) + end) + + it("should handle numeric string conversion correctly", function() + jwt_mock.verify = spy.new(function(secretKey, token) + return { + verified = true, + payload = "cip=192.168.1.1&uid=user123&cid=cookie123&isr=no_session&ist=not_a_number&grp=3600&mat=0&mit=0&cap=0&fCAPR=0" + } + end) + + local result = NetaceaCookies.parseMitataCookie("non_numeric_token", "secret_key") + + -- Should return invalid session for non-numeric timestamps + assert.is.table(result) + assert.is_false(result.valid) + assert.is.equal('invalid_session', result.reason) + end) + + it("should handle whitespace in client IP comparison", function() + jwt_mock.verify = spy.new(function(secretKey, token) + return { + verified = true, + payload = "cip= 192.168.1.1 &uid=user123&cid=cookie123&isr=no_session&ist=1640995200&grp=3600&mat=0&mit=0&cap=0&fCAPR=0" + } + end) + + local result = NetaceaCookies.parseMitataCookie("whitespace_ip_token", "secret_key") + + assert.is_false(result.valid) + assert.is.equal('ip_change', result.reason) + assert.is.equal('user123', result.user_id) + end) + end) + describe("decrypt", function() + it("should decrypt a valid JWT token", function() + local result = NetaceaCookies.decrypt("secret_key", "valid_token") + + assert.is_string(result) + assert.is.equal("cip=192.168.1.1&uid=user123&cid=cookie123&isr=no_session&ist=1640995200&grp=3600&mat=0&mit=0&cap=0&fCAPR=0", result) + assert.spy(jwt_mock.verify).was.called_with(match.is_not_nil(), "secret_key", "valid_token") + end) + + it("should return nil for invalid JWT token", function() + local result = NetaceaCookies.decrypt("secret_key", "invalid_token") + + assert.is_nil(result) + assert.spy(jwt_mock.verify).was.called_with(match.is_not_nil(), "secret_key", "invalid_token") + end) + + it("should return nil for unverified JWT token", function() + jwt_mock.verify = spy.new(function(self, secretKey, token) + return { verified = false } + end) + + local result = NetaceaCookies.decrypt("secret_key", "test_token") + + assert.is_nil(result) + assert.spy(jwt_mock.verify).was.called_with(match.is_not_nil(), "secret_key", "test_token") + end) + + it("should handle empty secret key", function() + local result = NetaceaCookies.decrypt("", "valid_token") + + assert.spy(jwt_mock.verify).was.called_with(match.is_not_nil(), "", "valid_token") + end) + + it("should handle empty token", function() + local result = NetaceaCookies.decrypt("secret_key", "") + + assert.spy(jwt_mock.verify).was.called_with(match.is_not_nil(), "secret_key", "") + end) + end) + + describe("encrypt", function() + it("should encrypt payload with correct JWT structure", function() + local _ = match._ + local result = NetaceaCookies.encrypt("secret_key", "test_payload") + + assert.is_string(result) + assert.is.equal("mock_jwt_token_secret_key", result) + assert.spy(jwt_mock.sign).was.called_with(match.is_not_nil(), "secret_key", { + header = { typ="JWE", alg="dir", enc="A128CBC-HS256" }, + payload = "test_payload" + }) + end) + + it("should handle empty payload", function() + local result = NetaceaCookies.encrypt("secret_key", "") + + assert.is_string(result) + assert.spy(jwt_mock.sign).was.called_with(match.is_not_nil(), "secret_key", { + header = { typ="JWE", alg="dir", enc="A128CBC-HS256" }, + payload = "" + }) + end) + + it("should handle nil payload", function() + local result = NetaceaCookies.encrypt("secret_key", nil) + + assert.is_string(result) + assert.spy(jwt_mock.sign).was.called_with(match.is_not_nil(), "secret_key", { + header = { typ="JWE", alg="dir", enc="A128CBC-HS256" }, + payload = nil + }) + end) + + it("should handle empty secret key", function() + local result = NetaceaCookies.encrypt("", "test_payload") + + assert.is.equal("mock_jwt_token_", result) + assert.spy(jwt_mock.sign).was.called_with(match.is_not_nil(), "", { + header = { typ="JWE", alg="dir", enc="A128CBC-HS256" }, + payload = "test_payload" + }) + end) + + it("should use correct JWT header values", function() + local _ = match._ + NetaceaCookies.encrypt("secret_key", "test_payload") + + assert.spy(jwt_mock.sign).was.called_with(match.is_not_nil(), "secret_key", match._) + + -- Verify header structure + local call_args = jwt_mock.sign.calls[1].vals + local jwt_params = call_args[3] + assert.is.table(jwt_params.header) + assert.is.table(jwt_params) + assert.is.truthy(jwt_params.header) + assert.is.truthy(jwt_params.payload) + assert.is.equal("JWE", jwt_params.header.typ) + assert.is.equal("dir", jwt_params.header.alg) + assert.is.equal("A128CBC-HS256", jwt_params.header.enc) + end) + end) + + describe("newUserId", function() + it("should generate a user ID starting with 'c' followed by 15 characters", function() + local userId = NetaceaCookies.newUserId() + + assert.is_string(userId) + assert.is.equal(16, #userId) + assert.is.equal('c', userId:sub(1,1)) + end) + + it("should generate different user IDs on multiple calls", function() + local userId1 = NetaceaCookies.newUserId() + local userId2 = NetaceaCookies.newUserId() + + assert.is_not.equal(userId1, userId2) + end) + + it("should generate user ID with alphanumeric characters", function() + local userId = NetaceaCookies.newUserId() + local pattern = "^c[%w_%-]+$" -- Alphanumeric, underscore, hyphen + + assert.is_true(userId:match(pattern) ~= nil, "User ID should match pattern: " .. pattern) + end) + + it("should always generate IDs of consistent length", function() + for i = 1, 10 do + local userId = NetaceaCookies.newUserId() + assert.is.equal(16, #userId, "ID " .. i .. " should be 16 characters long") + end + end) + + it("should generate only alphanumeric characters after 'c'", function() + local userId = NetaceaCookies.newUserId() + local randomPart = userId:sub(2) -- Everything after 'c' + local alphanumericPattern = "^[a-zA-Z0-9]+$" + + assert.is_true(randomPart:match(alphanumericPattern) ~= nil, + "Random part should contain only alphanumeric characters") + end) + end) +end) diff --git a/test/lua_resty_netacea_ingest_spec.lua b/test/lua_resty_netacea_ingest_spec.lua new file mode 100644 index 0000000..061c494 --- /dev/null +++ b/test/lua_resty_netacea_ingest_spec.lua @@ -0,0 +1,677 @@ +require("silence_g_write_guard") +require 'busted.runner'() + +package.path = "../src/?.lua;" .. package.path +local match = require("luassert.match") + +describe("lua_resty_netacea_ingest", function() + local Ingest + local ngx_mock + local kinesis_mock + local utils_mock + local cjson_mock + + before_each(function() + -- Mock ngx module + ngx_mock = { + ctx = { + mitata = "test_mitata_cookie", + NetaceaState = { + client = "192.168.1.1", + UserId = "user123", + bc_type = "captcha" + } + }, + var = { + request_method = "GET", + request_uri = "/test/path", + server_protocol = "HTTP/1.1", + time_local = "01/Jan/2022:00:00:00 +0000", + msec = 1640995200.123, + http_user_agent = "Test-Agent/1.0", + status = "200", + request_time = "0.123", + bytes_sent = "1024", + http_referer = "https://example.com", + query_string = "param=value", + host = "test.example.com", + request_id = "req-12345", + bytes_received = "512", + cookie__mitata = "fallback_mitata" + }, + log = spy.new(function() end), + DEBUG = 7, + ERR = 3, + now = spy.new(function() return 1640995200.5 end), + sleep = spy.new(function() end), + timer = { + at = spy.new(function(delay, callback) + -- Simulate timer execution for testing + if callback then + callback(false) -- premature = false + end + return true + end) + }, + thread = { + spawn = spy.new(function(func) + -- For testing, just return a mock thread handle + return { thread_id = "mock_thread" } + end), + wait = spy.new(function(thread) + return true, nil -- success, no error + end) + }, + worker = { + exiting = spy.new(function() return false end) + } + } + + -- Mock kinesis module + kinesis_mock = { + new = spy.new(function(stream_name, region, access_key, secret_key) + local client = { + stream_name = stream_name, + region = region, + access_key = access_key, + secret_key = secret_key, + put_records = spy.new(function(self, records) + if self.stream_name == "test_stream" or self.stream_name == "integration_test_stream" or self.stream_name:match("test") then + return { status = 200, body = '{"Records":[]}' }, nil + else + return nil, "Stream not found" + end + end) + } + return client + end) + } + + -- Mock utils module + utils_mock = { + buildRandomString = spy.new(function(length) + return string.rep("a", length) + end), + getIpAddress = spy.new(function(self, vars, header) + if header and vars["http_" .. header] then + return vars["http_" .. header] + end + return vars.remote_addr or "127.0.0.1" + end) + } + + -- Mock cjson module + cjson_mock = { + encode = spy.new(function(obj) + if type(obj) == "table" then + return '{"mocked":"json"}' + end + return '"' .. tostring(obj) .. '"' + end) + } + + -- Set up package mocks + package.loaded['ngx'] = ngx_mock + package.loaded['kinesis_resty'] = kinesis_mock + package.loaded['netacea_utils'] = utils_mock + package.loaded['cjson'] = cjson_mock + + Ingest = require('lua_resty_netacea_ingest') + end) + + after_each(function() + -- Clear mocks and cached modules + package.loaded['lua_resty_netacea_ingest'] = nil + package.loaded['ngx'] = nil + package.loaded['kinesis_resty'] = nil + package.loaded['netacea_utils'] = nil + package.loaded['cjson'] = nil + end) + + describe("new_queue", function() + it("should create a queue with specified size", function() + local ingest = Ingest:new({ + stream_name = "test_stream", + queue_size = 10 + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + + assert.is.table(ingest.data_queue) + assert.is.equal(10, ingest.data_queue.size) + assert.is.equal(0, ingest.data_queue:count()) + end) + + it("should support push and pop operations", function() + local ingest = Ingest:new({ + stream_name = "test_stream", + queue_size = 5 + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + + -- Test push + local ok, err = ingest.data_queue:push("item1") + assert.is_true(ok) + assert.is_nil(err) + assert.is.equal(1, ingest.data_queue:count()) + + -- Test pop + local item = ingest.data_queue:pop() + assert.is.equal("item1", item) + assert.is.equal(0, ingest.data_queue:count()) + end) + + it("should handle queue wrapping when enabled", function() + local ingest = Ingest:new({ + stream_name = "test_stream", + queue_size = 2 + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + + -- Fill the queue + ingest.data_queue:push("item1") + ingest.data_queue:push("item2") + assert.is.equal(2, ingest.data_queue:count()) + + -- Push beyond capacity (should wrap since allow_wrapping is true) + ingest.data_queue:push("item3") + assert.is.equal(2, ingest.data_queue:count()) + + -- First item should be lost, second item should be available + local item = ingest.data_queue:pop() + assert.is.equal("item2", item) + end) + + it("should peek at next item without removing it", function() + local ingest = Ingest:new({ + stream_name = "test_stream", + queue_size = 5 + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + + ingest.data_queue:push("peek_item") + + local peeked = ingest.data_queue:peek() + assert.is.equal("peek_item", peeked) + assert.is.equal(1, ingest.data_queue:count()) + + local popped = ingest.data_queue:pop() + assert.is.equal("peek_item", popped) + assert.is.equal(0, ingest.data_queue:count()) + end) + end) + + describe("constructor", function() + it("should create ingest instance with default options", function() + local ingest = Ingest:new({}, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + + assert.is.table(ingest) + assert.is.equal("", ingest.stream_name) + assert.is.equal("eu-west-1", ingest.region) + assert.is.equal("", ingest.aws_access_key) + assert.is.equal("", ingest.aws_secret_key) + assert.is.equal(5000, ingest.queue_size) + assert.is.equal(1000, ingest.dead_letter_queue_size) + assert.is.equal(25, ingest.batch_size) + assert.is.equal(1.0, ingest.batch_timeout) + end) + + it("should create ingest instance with custom options", function() + local options = { + stream_name = "my-stream", + region = "us-east-1", + aws_access_key = "AKIAIOSFODNN7EXAMPLE", + aws_secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + queue_size = 1000, + dead_letter_queue_size = 100, + batch_size = 50, + batch_timeout = 2.0 + } + + local ingest = Ingest:new(options, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + + assert.is.equal("my-stream", ingest.stream_name) + assert.is.equal("us-east-1", ingest.region) + assert.is.equal("AKIAIOSFODNN7EXAMPLE", ingest.aws_access_key) + assert.is.equal("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", ingest.aws_secret_key) + assert.is.equal(1000, ingest.queue_size) + assert.is.equal(100, ingest.dead_letter_queue_size) + assert.is.equal(50, ingest.batch_size) + assert.is.equal(2.0, ingest.batch_timeout) + end) + + it("should initialize queues properly", function() + local ingest = Ingest:new({ + queue_size = 10, + dead_letter_queue_size = 5 + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + + assert.is.table(ingest.data_queue) + assert.is.table(ingest.dead_letter_queue) + assert.is.equal(10, ingest.data_queue.size) + assert.is.equal(5, ingest.dead_letter_queue.size) + assert.is.equal(0, ingest.data_queue:count()) + assert.is.equal(0, ingest.dead_letter_queue:count()) + end) + + it("should log initialization message", function() + Ingest:new({ + queue_size = 100, + dead_letter_queue_size = 50, + batch_size = 10, + batch_timeout = 0.5 + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + + assert.spy(ngx_mock.log).was.called_with(ngx_mock.DEBUG, match._, 100, match._, 50, match._, 10, match._, 0.5) + end) + end) + + describe("send_batch_to_kinesis", function() + local ingest + + before_each(function() + ingest = Ingest:new({ + stream_name = "test_stream", + region = "us-west-2", + aws_access_key = "test_key", + aws_secret_key = "test_secret" + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + end) + + it("should handle empty batch gracefully", function() + ingest:send_batch_to_kinesis({}) + + -- Should not call Kinesis + assert.spy(kinesis_mock.new).was_not_called() + end) + + it("should handle nil batch gracefully", function() + ingest:send_batch_to_kinesis(nil) + + -- Should not call Kinesis + assert.spy(kinesis_mock.new).was_not_called() + end) + + it("should create kinesis client with correct parameters", function() + local batch = { { test = "data1" }, { test = "data2" } } + + ingest:send_batch_to_kinesis(batch) + + assert.spy(kinesis_mock.new).was.called_with( + "test_stream", + "us-west-2", + "test_key", + "test_secret" + ) + end) + + it("should format batch data correctly for kinesis", function() + local batch = { { test = "data1" }, { test = "data2" } } + + ingest:send_batch_to_kinesis(batch) + + assert.spy(kinesis_mock.new).was.called() + assert.spy(utils_mock.buildRandomString).was.called(2) -- Once per record for partition key + assert.spy(cjson_mock.encode).was.called(2) -- Once per record + end) + + it("should log successful batch send", function() + local batch = { { test = "data1" } } + + ingest:send_batch_to_kinesis(batch) + + assert.spy(ngx_mock.log).was.called_with(ngx_mock.DEBUG, match.has_match("sending batch of"), 1, match._, "test_stream") + -- Check that at least one log call was made (for success) + assert.spy(ngx_mock.log).was.called() + end) + + it("should handle kinesis errors and move items to dead letter queue", function() + -- Mock kinesis to return error + local batch = { { test = "data1" }, { test = "data2" } } + kinesis_mock.new = spy.new(function() + return { + put_records = spy.new(function() + return nil, "Connection timeout" + end) + } + end) + + ingest:send_batch_to_kinesis(batch) + + assert.spy(ngx_mock.log).was.called_with(ngx_mock.ERR, match.has_match("error sending batch"), "Connection timeout") + assert.is.equal(2, ingest.dead_letter_queue:count()) + end) + + it("should handle dead letter queue overflow correctly", function() + -- Mock kinesis error + kinesis_mock.new = spy.new(function() + return { + put_records = spy.new(function() + return nil, "Stream not found" + end) + } + end) + + -- Create an ingest with very small DLQ size for testing + local small_dlq_ingest = Ingest:new({ + stream_name = "test_stream", + dead_letter_queue_size = 1 + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + + -- Fill DLQ to capacity first (the queue allows wrapping so it overwrites) + small_dlq_ingest.dead_letter_queue:push("existing_item") + + -- Now try to send a batch that will fail + local batch = { { test = "overflow_data1" } } + small_dlq_ingest:send_batch_to_kinesis(batch) + + -- Should log the kinesis error + assert.spy(ngx_mock.log).was.called_with(ngx_mock.ERR, match.has_match("error sending batch"), "Stream not found") + -- The DLQ should now contain the failed item (wrapping behavior) + assert.is.equal(1, small_dlq_ingest.dead_letter_queue:count()) + end) + end) + + describe("ingest", function() + local ingest + + before_each(function() + ingest = Ingest:new({ + stream_name = "test_stream" + }, { + _MODULE_TYPE = "nginx", + _MODULE_VERSION = "2.1.0", + realIpHeader = "x_forwarded_for", + mitigationType = "monitor" + }) + end) + + it("should collect request data from ngx variables", function() + ingest:ingest() + + assert.is.equal(1, ingest.data_queue:count()) + local queued_item = ingest.data_queue:pop() + + assert.is.table(queued_item) + assert.is.equal("GET /test/path HTTP/1.1", queued_item.Request) + assert.is.equal("01/Jan/2022:00:00:00 +0000", queued_item.TimeLocal) + assert.is.equal(1640995200123, queued_item.TimeUnixMsUTC) + assert.is.equal("192.168.1.1", queued_item.RealIp) + assert.is.equal("Test-Agent/1.0", queued_item.UserAgent) + assert.is.equal("200", queued_item.Status) + assert.is.equal("0.123", queued_item.RequestTime) + assert.is.equal("1024", queued_item.BytesSent) + assert.is.equal("https://example.com", queued_item.Referer) + assert.is.equal("test_mitata_cookie", queued_item.NetaceaUserIdCookie) + assert.is.equal("user123", queued_item.UserId) + assert.is.equal("captcha", queued_item.NetaceaMitigationApplied) + assert.is.equal("nginx", queued_item.IntegrationType) + assert.is.equal("2.1.0", queued_item.IntegrationVersion) + assert.is.equal("param=value", queued_item.Query) + assert.is.equal("test.example.com", queued_item.RequestHost) + assert.is.equal("req-12345", queued_item.RequestId) + assert.is.equal("monitor", queued_item.ProtectionMode) + end) + + it("should handle missing ngx.ctx.mitata by falling back to cookie", function() + ngx_mock.ctx.mitata = nil + + ingest:ingest() + + local queued_item = ingest.data_queue:pop() + assert.is.equal("fallback_mitata", queued_item.NetaceaUserIdCookie) + end) + + it("should handle missing NetaceaState gracefully", function() + ngx_mock.ctx.NetaceaState = nil + + ingest:ingest() + + local queued_item = ingest.data_queue:pop() + assert.is.equal("127.0.0.1", queued_item.RealIp) -- default from utils mock + assert.is.equal("-", queued_item.UserId) + assert.is_nil(queued_item.NetaceaMitigationApplied) + end) + + it("should use realIpHeader when configured", function() + ngx_mock.var.http_x_forwarded_for = "203.0.113.1" + ngx_mock.ctx.NetaceaState.client = nil -- Force it to use getIpAddress + + ingest:ingest() + + assert.spy(utils_mock.getIpAddress).was.called() + -- Verify the call was made with correct number of arguments + assert.is.equal(1, #utils_mock.getIpAddress.calls) + end) + + it("should handle missing optional fields with defaults", function() + ngx_mock.var.http_user_agent = nil + ngx_mock.var.http_referer = nil + ngx_mock.var.query_string = nil + ngx_mock.var.host = nil + ngx_mock.var.request_id = nil + ngx_mock.var.bytes_received = nil + + ingest:ingest() + + local queued_item = ingest.data_queue:pop() + assert.is.equal("-", queued_item.UserAgent) + assert.is.equal("-", queued_item.Referer) + assert.is.equal("", queued_item.Query) + assert.is.equal("-", queued_item.RequestHost) + assert.is.equal("-", queued_item.RequestId) + assert.is.equal(0, queued_item.BytesReceived) + end) + + it("should log successful data queueing", function() + ingest:ingest() + + assert.spy(ngx_mock.log).was.called_with(ngx_mock.DEBUG, match.has_match("queued data item"), 1) + end) + + it("should log error when queue is full", function() + -- Fill the queue to capacity + for i = 1, ingest.queue_size do + ingest.data_queue:push("item" .. i) + end + + -- Since wrapping is enabled, this should still work, but let's test error handling + -- by mocking the queue to return an error + ingest.data_queue.push = spy.new(function() return nil, "queue full" end) + + ingest:ingest() + + assert.spy(ngx_mock.log).was.called_with(ngx_mock.ERR, match.has_match("failed to queue data"), "queue full") + end) + + it("should include all required data fields", function() + ingest:ingest() + + local queued_item = ingest.data_queue:pop() + local required_fields = { + "Request", "TimeLocal", "TimeUnixMsUTC", "RealIp", "UserAgent", "Status", + "RequestTime", "BytesSent", "Referer", "NetaceaUserIdCookie", "UserId", + "NetaceaMitigationApplied", "IntegrationType", "IntegrationVersion", "Query", + "RequestHost", "RequestId", "ProtectionMode", "BytesReceived", + "NetaceaUserIdCookieStatus", "Optional" + } + + for _, field in ipairs(required_fields) do + assert.is_not_nil(queued_item[field], "Field " .. field .. " should be present") + end + end) + + it("should handle empty mitata cookie", function() + ngx_mock.ctx.mitata = "" + ngx_mock.var.cookie__mitata = "" + + ingest:ingest() + + local queued_item = ingest.data_queue:pop() + assert.is.equal("", queued_item.NetaceaUserIdCookie) + end) + end) + + describe("start_timers", function() + local ingest + + before_each(function() + ingest = Ingest:new({ + stream_name = "test_stream", + batch_size = 2, + batch_timeout = 0.1 + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + + -- Simplify timer mock to avoid recursion + ngx_mock.timer.at = spy.new(function(delay, callback) + return true -- Just return success, don't execute callback + end) + end) + + it("should start batch processor timer", function() + ingest:start_timers() + + assert.spy(ngx_mock.timer.at).was.called_with(0, match.is_function()) + assert.spy(ngx_mock.log).was.called_with(ngx_mock.DEBUG, match.has_match("starting batch processor timer")) + end) + + it("should setup timer correctly", function() + ingest:start_timers() + + assert.spy(ngx_mock.timer.at).was.called() + end) + end) + + describe("queue integration with kinesis", function() + local ingest + + before_each(function() + ingest = Ingest:new({ + stream_name = "integration_test_stream", + batch_size = 3, + batch_timeout = 0.1 + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + end) + + it("should process data from queue through kinesis pipeline", function() + -- Add test data to queue + ingest.data_queue:push({ test_data = "item1" }) + ingest.data_queue:push({ test_data = "item2" }) + ingest.data_queue:push({ test_data = "item3" }) + + -- Manually call send_batch_to_kinesis to test integration + local batch = {} + while ingest.data_queue:count() > 0 do + table.insert(batch, ingest.data_queue:pop()) + end + + ingest:send_batch_to_kinesis(batch) + + assert.spy(kinesis_mock.new).was.called() + assert.spy(utils_mock.buildRandomString).was.called(3) -- Once per record for partition key + assert.spy(cjson_mock.encode).was.called(3) -- Once per record for data serialization + end) + + it("should handle dead letter queue processing", function() + -- Add items to dead letter queue + ingest.dead_letter_queue:push({ failed_data = "item1" }) + ingest.dead_letter_queue:push({ failed_data = "item2" }) + + -- Process dead letter queue items + local batch = {} + while ingest.dead_letter_queue:count() > 0 do + table.insert(batch, ingest.dead_letter_queue:pop()) + end + + ingest:send_batch_to_kinesis(batch) + + assert.spy(kinesis_mock.new).was.called() + -- Verify kinesis new was called (put_records called internally) + assert.spy(kinesis_mock.new).was.called(1) + end) + + it("should handle mixed data and dead letter queue processing", function() + -- Add items to both queues + ingest.data_queue:push({ normal_data = "item1" }) + ingest.dead_letter_queue:push({ retry_data = "item2" }) + + local batch = {} + + -- Process dead letter queue first (as per implementation) + while ingest.dead_letter_queue:count() > 0 and #batch < ingest.batch_size do + table.insert(batch, ingest.dead_letter_queue:pop()) + end + + -- Then process normal queue + while ingest.data_queue:count() > 0 and #batch < ingest.batch_size do + table.insert(batch, ingest.data_queue:pop()) + end + + ingest:send_batch_to_kinesis(batch) + + assert.is.equal(2, #batch) + assert.spy(kinesis_mock.new).was.called() + end) + end) + + describe("AWS Kinesis integration specifics", function() + local ingest + + before_each(function() + ingest = Ingest:new({ + stream_name = "aws_test_stream", + region = "us-east-1", + aws_access_key = "AKIA1234567890EXAMPLE", + aws_secret_key = "abcd1234567890efgh1234567890ijklmnopqrstuvwx" + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + end) + + it("should create kinesis client with AWS credentials", function() + local batch = { { aws_test = "data" } } + + ingest:send_batch_to_kinesis(batch) + + assert.spy(kinesis_mock.new).was.called_with( + "aws_test_stream", + "us-east-1", + "AKIA1234567890EXAMPLE", + "abcd1234567890efgh1234567890ijklmnopqrstuvwx" + ) + end) + + it("should generate random partition keys for records", function() + local batch = { { data1 = "test" }, { data2 = "test" } } + + ingest:send_batch_to_kinesis(batch) + + assert.spy(utils_mock.buildRandomString).was.called(2) + assert.spy(utils_mock.buildRandomString).was.called_with(10) + end) + + it("should format data as JSON arrays for Kinesis", function() + local test_data = { field1 = "value1", field2 = "value2" } + local batch = { test_data } + + ingest:send_batch_to_kinesis(batch) + + assert.spy(kinesis_mock.new).was.called() + + -- The put_records call happens internally, we can verify the call structure + -- by checking that kinesis_mock.new was called properly + assert.spy(kinesis_mock.new).was.called_with( + "aws_test_stream", + "us-east-1", + "AKIA1234567890EXAMPLE", + "abcd1234567890efgh1234567890ijklmnopqrstuvwx" + ) + end) + + it("should handle different AWS regions", function() + local regional_ingest = Ingest:new({ + stream_name = "eu_stream", + region = "eu-west-1", + aws_access_key = "key", + aws_secret_key = "secret" + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + + regional_ingest:send_batch_to_kinesis({ { regional_test = "data" } }) + + assert.spy(kinesis_mock.new).was.called_with("eu_stream", "eu-west-1", "key", "secret") + end) + end) +end) \ No newline at end of file diff --git a/test/lua_resty_netacea_spec.lua b/test/lua_resty_netacea_spec.lua new file mode 100644 index 0000000..9edf206 --- /dev/null +++ b/test/lua_resty_netacea_spec.lua @@ -0,0 +1,12 @@ +require("silence_g_write_guard") +require 'busted.runner'() +-- Test file for lua_resty_netacea + +insulate("lua_resty_netacea", function() + + describe("lua_resty_netacea", function() + it("should always pass", function() + assert.is_true(true) + end) + end) +end) \ No newline at end of file diff --git a/test/netacea_utils.test.lua b/test/netacea_utils.test.lua deleted file mode 100644 index 9feac13..0000000 --- a/test/netacea_utils.test.lua +++ /dev/null @@ -1,177 +0,0 @@ -require 'busted.runner'() - -package.path = "../src/?.lua;" .. package.path - -describe("netacea_utils", function() - local utils - - before_each(function() - utils = require('netacea_utils') - end) - - after_each(function() - package.loaded['netacea_utils'] = nil - end) - - describe("buildRandomString", function() - it("should generate a string of the specified length", function() - local result = utils.buildRandomString(10) - assert.is.equal(10, #result) - end) - - it("should generate a string of length 1 when passed 1", function() - local result = utils.buildRandomString(1) - assert.is.equal(1, #result) - end) - - it("should generate an empty string when passed 0", function() - local result = utils.buildRandomString(0) - assert.is.equal(0, #result) - assert.is.equal('', result) - end) - - it("should generate strings with only alphanumeric characters", function() - local result = utils.buildRandomString(50) - local validChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' - - for i = 1, #result do - local char = result:sub(i, i) - assert.is_truthy(validChars:find(char, 1, true), "Character '" .. char .. "' should be alphanumeric") - end - end) - - it("should generate different strings on multiple calls", function() - local result1 = utils.buildRandomString(20) - local result2 = utils.buildRandomString(20) - - -- While theoretically possible to be equal, it's extremely unlikely - -- with 62^20 possible combinations - assert.is_not.equal(result1, result2) - end) - - it("should handle large string lengths", function() - local result = utils.buildRandomString(1000) - assert.is.equal(1000, #result) - end) - - it("should contain at least some variety in character types for longer strings", function() - local result = utils.buildRandomString(100) - - -- Check that we have at least some variety (not all the same character) - local firstChar = result:sub(1, 1) - local hasVariety = false - - for i = 2, #result do - if result:sub(i, i) ~= firstChar then - hasVariety = true - break - end - end - - assert.is_true(hasVariety, "String should contain character variety") - end) - end) - - describe("getIpAddress", function() - it("should return remote_addr when realIpHeader is nil", function() - local vars = { - remote_addr = "192.168.1.1" - } - - local result = utils:getIpAddress(vars, nil) - assert.is.equal("192.168.1.1", result) - end) - - it("should return remote_addr when realIpHeader is not provided", function() - local vars = { - remote_addr = "192.168.1.1" - } - - local result = utils:getIpAddress(vars) - assert.is.equal("192.168.1.1", result) - end) - - it("should return remote_addr when realIpHeader is empty string", function() - local vars = { - remote_addr = "192.168.1.1" - } - - local result = utils:getIpAddress(vars, "") - assert.is.equal("192.168.1.1", result) - end) - - it("should return the real IP header value when it exists", function() - local vars = { - remote_addr = "192.168.1.1", - http_x_forwarded_for = "203.0.113.1" - } - - local result = utils:getIpAddress(vars, "x_forwarded_for") - assert.is.equal("203.0.113.1", result) - end) - - it("should return remote_addr when real IP header doesn't exist", function() - local vars = { - remote_addr = "192.168.1.1" - } - - local result = utils:getIpAddress(vars, "x_forwarded_for") - assert.is.equal("192.168.1.1", result) - end) - - it("should handle different header formats", function() - local vars = { - remote_addr = "192.168.1.1", - http_x_real_ip = "203.0.113.2", - http_cf_connecting_ip = "203.0.113.3" - } - - local result1 = utils:getIpAddress(vars, "x_real_ip") - assert.is.equal("203.0.113.2", result1) - - local result2 = utils:getIpAddress(vars, "cf_connecting_ip") - assert.is.equal("203.0.113.3", result2) - end) - - it("should fall back to remote_addr when real IP header is empty", function() - local vars = { - remote_addr = "192.168.1.1", - http_x_forwarded_for = "" - } - - local result = utils:getIpAddress(vars, "x_forwarded_for") - assert.is.equal("192.168.1.1", result) - end) - - it("should fall back to remote_addr when real IP header is nil", function() - local vars = { - remote_addr = "192.168.1.1", - http_x_forwarded_for = nil - } - - local result = utils:getIpAddress(vars, "x_forwarded_for") - assert.is.equal("192.168.1.1", result) - end) - - it("should handle IPv6 addresses", function() - local vars = { - remote_addr = "2001:db8::1", - http_x_forwarded_for = "2001:db8::2" - } - - local result = utils:getIpAddress(vars, "x_forwarded_for") - assert.is.equal("2001:db8::2", result) - end) - - it("should handle special header names with underscores and dashes", function() - local vars = { - remote_addr = "192.168.1.1", - ["http_x_forwarded_for"] = "203.0.113.1", - ["http_x_real_ip"] = "203.0.113.2" - } - - local result = utils:getIpAddress(vars, "x_forwarded_for") - assert.is.equal("203.0.113.1", result) - end) - end) -end) diff --git a/test/netacea_utils_spec.lua b/test/netacea_utils_spec.lua new file mode 100644 index 0000000..f6ea611 --- /dev/null +++ b/test/netacea_utils_spec.lua @@ -0,0 +1,380 @@ +require("silence_g_write_guard") +require 'busted.runner'() + +package.path = "../src/?.lua;" .. package.path + +describe("netacea_utils", function() + local utils + + before_each(function() + utils = require('netacea_utils') + end) + + after_each(function() + package.loaded['netacea_utils'] = nil + end) + + describe("buildRandomString", function() + it("should generate a string of the specified length", function() + local result = utils.buildRandomString(10) + assert.is.equal(10, #result) + end) + + it("should generate a string of length 1 when passed 1", function() + local result = utils.buildRandomString(1) + assert.is.equal(1, #result) + end) + + it("should generate an empty string when passed 0", function() + local result = utils.buildRandomString(0) + assert.is.equal(0, #result) + assert.is.equal('', result) + end) + + it("should generate strings with only alphanumeric characters", function() + local result = utils.buildRandomString(50) + local validChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + + for i = 1, #result do + local char = result:sub(i, i) + assert.is_truthy(validChars:find(char, 1, true), "Character '" .. char .. "' should be alphanumeric") + end + end) + + it("should generate different strings on multiple calls", function() + local result1 = utils.buildRandomString(20) + local result2 = utils.buildRandomString(20) + + -- While theoretically possible to be equal, it's extremely unlikely + -- with 62^20 possible combinations + assert.is_not.equal(result1, result2) + end) + + it("should handle large string lengths", function() + local result = utils.buildRandomString(1000) + assert.is.equal(1000, #result) + end) + + it("should contain at least some variety in character types for longer strings", function() + local result = utils.buildRandomString(100) + + -- Check that we have at least some variety (not all the same character) + local firstChar = result:sub(1, 1) + local hasVariety = false + + for i = 2, #result do + if result:sub(i, i) ~= firstChar then + hasVariety = true + break + end + end + + assert.is_true(hasVariety, "String should contain character variety") + end) + end) + + describe("getIpAddress", function() + it("should return remote_addr when realIpHeader is nil", function() + local vars = { + remote_addr = "192.168.1.1" + } + + local result = utils:getIpAddress(vars, nil) + assert.is.equal("192.168.1.1", result) + end) + + it("should return remote_addr when realIpHeader is not provided", function() + local vars = { + remote_addr = "192.168.1.1" + } + + local result = utils:getIpAddress(vars) + assert.is.equal("192.168.1.1", result) + end) + + it("should return remote_addr when realIpHeader is empty string", function() + local vars = { + remote_addr = "192.168.1.1" + } + + local result = utils:getIpAddress(vars, "") + assert.is.equal("192.168.1.1", result) + end) + + it("should return the real IP header value when it exists", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_forwarded_for = "203.0.113.1" + } + + local result = utils:getIpAddress(vars, "x_forwarded_for") + assert.is.equal("203.0.113.1", result) + end) + + it("should return remote_addr when real IP header doesn't exist", function() + local vars = { + remote_addr = "192.168.1.1" + } + + local result = utils:getIpAddress(vars, "x_forwarded_for") + assert.is.equal("192.168.1.1", result) + end) + + it("should handle different header formats", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_real_ip = "203.0.113.2", + http_cf_connecting_ip = "203.0.113.3" + } + + local result1 = utils:getIpAddress(vars, "x_real_ip") + assert.is.equal("203.0.113.2", result1) + + local result2 = utils:getIpAddress(vars, "cf_connecting_ip") + assert.is.equal("203.0.113.3", result2) + end) + + it("should fall back to remote_addr when real IP header is empty", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_forwarded_for = "" + } + + local result = utils:getIpAddress(vars, "x_forwarded_for") + assert.is.equal("192.168.1.1", result) + end) + + it("should fall back to remote_addr when real IP header is nil", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_forwarded_for = nil + } + + local result = utils:getIpAddress(vars, "x_forwarded_for") + assert.is.equal("192.168.1.1", result) + end) + + it("should handle IPv6 addresses", function() + local vars = { + remote_addr = "2001:db8::1", + http_x_forwarded_for = "2001:db8::2" + } + + local result = utils:getIpAddress(vars, "x_forwarded_for") + assert.is.equal("2001:db8::2", result) + end) + + it("should handle special header names with underscores and dashes", function() + local vars = { + remote_addr = "192.168.1.1", + ["http_x_forwarded_for"] = "203.0.113.1", + ["http_x_real_ip"] = "203.0.113.2" + } + + local result = utils:getIpAddress(vars, "x_forwarded_for") + assert.is.equal("203.0.113.1", result) + end) + + it("should handle missing remote_addr gracefully", function() + local vars = { + http_x_forwarded_for = "203.0.113.1" + } + + local result = utils:getIpAddress(vars, "x_forwarded_for") + assert.is.equal("203.0.113.1", result) + end) + + it("should handle nil vars table", function() + local success, result = pcall(function() + return utils:getIpAddress(nil, "x_forwarded_for") + end) + + -- Should not crash, but will likely error when trying to access nil.remote_addr + assert.is_false(success) + end) + + it("should handle empty vars table", function() + local vars = {} + + local result = utils:getIpAddress(vars, "x_forwarded_for") + assert.is_nil(result) -- vars.remote_addr is nil + end) + + it("should handle whitespace in header values", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_forwarded_for = " 203.0.113.1 " + } + + local result = utils:getIpAddress(vars, "x_forwarded_for") + assert.is.equal(" 203.0.113.1 ", result) -- Should preserve whitespace + end) + + it("should handle very long header names", function() + local longHeaderName = string.rep("a", 100) + local vars = { + remote_addr = "192.168.1.1", + ["http_" .. longHeaderName] = "203.0.113.1" + } + + local result = utils:getIpAddress(vars, longHeaderName) + assert.is.equal("203.0.113.1", result) + end) + end) + + describe("parseOption", function() + it("should return the option when it's a valid string", function() + local result = utils.parseOption("test_value", "default") + assert.is.equal("test_value", result) + end) + + it("should return the default value when option is nil", function() + local result = utils.parseOption(nil, "default_value") + assert.is.equal("default_value", result) + end) + + it("should return the default value when option is empty string", function() + local result = utils.parseOption("", "default_value") + assert.is.equal("default_value", result) + end) + + it("should trim whitespace from string options", function() + local result = utils.parseOption(" test_value ", "default") + assert.is.equal("test_value", result) + end) + + it("should handle leading whitespace only", function() + local result = utils.parseOption(" test_value", "default") + assert.is.equal("test_value", result) + end) + + it("should handle trailing whitespace only", function() + local result = utils.parseOption("test_value ", "default") + assert.is.equal("test_value", result) + end) + + it("should return default when option is only whitespace", function() + local result = utils.parseOption(" ", "default_value") + assert.is.equal("default_value", result) + end) + + it("should handle tabs and newlines in whitespace", function() + local result = utils.parseOption("\t\n test_value \t\n", "default") + assert.is.equal("test_value", result) + end) + + it("should preserve internal whitespace", function() + local result = utils.parseOption(" test value with spaces ", "default") + assert.is.equal("test value with spaces", result) + end) + + it("should handle non-string types by returning them as-is", function() + local numberResult = utils.parseOption(123, "default") + assert.is.equal(123, numberResult) + + local boolResult = utils.parseOption(true, "default") + assert.is.equal(true, boolResult) + + local tableResult = utils.parseOption({test = "value"}, "default") + assert.is.same({test = "value"}, tableResult) + end) + + it("should handle nil default value", function() + local result = utils.parseOption(nil, nil) + assert.is_nil(result) + end) + + it("should handle empty string as default value", function() + local result = utils.parseOption(nil, "") + assert.is.equal("", result) + end) + + it("should handle numeric default values", function() + local result = utils.parseOption(nil, 42) + assert.is.equal(42, result) + end) + + it("should handle boolean default values", function() + local result = utils.parseOption(nil, false) + assert.is.equal(false, result) + end) + + it("should handle complex whitespace patterns", function() + -- Various Unicode whitespace characters + local result = utils.parseOption("\r\n\t test \t\r\n", "default") + assert.is.equal("test", result) + end) + + it("should handle single character strings", function() + local result = utils.parseOption(" a ", "default") + assert.is.equal("a", result) + end) + + it("should handle very long strings", function() + local longString = " " .. string.rep("a", 10000) .. " " + local result = utils.parseOption(longString, "default") + assert.is.equal(string.rep("a", 10000), result) + end) + + it("should handle special characters in strings", function() + local result = utils.parseOption(" !@#$%^&*() ", "default") + assert.is.equal("!@#$%^&*()", result) + end) + + it("should handle Unicode characters", function() + local result = utils.parseOption(" Hello 世界 ", "default") + assert.is.equal("Hello 世界", result) + end) + + it("should handle empty string after trimming", function() + local result = utils.parseOption("\t\n\r ", "default_value") + assert.is.equal("default_value", result) + end) + end) + + describe("buildRandomString edge cases", function() + it("should handle negative length gracefully", function() + -- The current implementation doesn't check for negative values + -- This might cause unexpected behavior + local success, result = pcall(function() + return utils.buildRandomString(-1) + end) + + if success then + -- If it doesn't error, it should return empty string + assert.is.equal("", result) + else + -- If it errors, that's also acceptable behavior + assert.is_true(true) + end + end) + + it("should handle very large lengths", function() + -- Test with a reasonably large number (not too large to avoid memory issues) + local result = utils.buildRandomString(10000) + assert.is.equal(10000, #result) + end) + + it("should maintain randomness across multiple calls with same seed conditions", function() + -- Since the function sets its own seed based on time, multiple rapid calls + -- might produce similar results, but we test that the seeding works + local results = {} + for i = 1, 10 do + results[i] = utils.buildRandomString(20) + -- Small delay to ensure different seeds + os.execute("sleep 0.001") + end + + -- Check that not all results are the same + local allSame = true + for i = 2, 10 do + if results[i] ~= results[1] then + allSame = false + break + end + end + + assert.is_false(allSame, "Random strings should vary across multiple calls") + end) + end) +end)