Files
luci/applications/luci-app-https-dns-proxy/tests/run_tests.sh
Stan Grishin 60b1eac5a4 luci-app-https-dns-proxy: update to 2025.12.29-4
This commit adds support for:
* new unified provider.json support to save space compared to individual
  provider json files
* new v.recipes provider (thanks @morytyann!)
* functional testing
* minor js/shell code improvements

Signed-off-by: Stan Grishin <stangri@melmac.ca>
2026-03-03 17:44:02 -08:00

485 lines
15 KiB
Bash

#!/usr/bin/env bash
# Functional test runner for luci-app-https-dns-proxy.
#
# Tests:
# 01: Provider JSON validation (structure, fields, bootstrap DNS)
# 02: JavaScript template functions (templateToRegexp, templateToResolver)
# 03: RPC backend script validation
#
# Usage: cd source.openwrt.melmac.ca/luci-app-https-dns-proxy && bash tests/run_tests.sh
set -o pipefail
n_tests=0
n_fails=0
pass() {
printf " PASS: %s\n" "$1"
}
fail() {
printf " FAIL: %s\n" "$1"
[ -n "$2" ] && printf " %s\n" "$2"
n_fails=$((n_fails + 1))
}
assert_rc() {
local desc="$1" expect="$2" actual="$3"
n_tests=$((n_tests + 1))
if [ "$expect" -eq "$actual" ]; then
pass "$desc"
else
fail "$desc" "expected rc=$expect, got rc=$actual"
fi
}
assert_eq() {
local desc="$1" expect="$2" actual="$3"
n_tests=$((n_tests + 1))
if [ "$expect" = "$actual" ]; then
pass "$desc"
else
fail "$desc" "expected: '$expect', got: '$actual'"
fi
}
PROVIDERS_DIR="./root/usr/share/https-dns-proxy/providers"
STATUS_JS="./htdocs/luci-static/resources/https-dns-proxy/status.js"
RPC_SCRIPT="./root/usr/libexec/rpcd/luci.https-dns-proxy"
if [ ! -d "$PROVIDERS_DIR" ]; then
echo "ERROR: Cannot find $PROVIDERS_DIR. Run from the luci-app-https-dns-proxy package root."
exit 1
fi
###############################################################################
# 01: Provider JSON validation #
###############################################################################
printf "\n##\n## 01: Provider JSON validation\n##\n\n"
provider_count=0
for f in "$PROVIDERS_DIR"/*.json; do
[ -f "$f" ] || continue
provider_count=$((provider_count + 1))
base="$(basename "$f")"
# ── Valid JSON ──
n_tests=$((n_tests + 1))
if python3 -m json.tool "$f" >/dev/null 2>&1; then
pass "$base: valid JSON"
else
fail "$base: invalid JSON"
continue
fi
content="$(python3 -m json.tool "$f")"
# ── Required field: title ──
n_tests=$((n_tests + 1))
title="$(python3 -c "import json,sys; d=json.load(open('$f')); print(d.get('title',''))")"
if [ -n "$title" ]; then
pass "$base: has title '$title'"
else
fail "$base: missing 'title' field"
fi
# ── Required field: template ──
n_tests=$((n_tests + 1))
template="$(python3 -c "import json,sys; d=json.load(open('$f')); print(d.get('template',''))")"
if [ -n "$template" ]; then
pass "$base: has template"
else
fail "$base: missing 'template' field"
continue
fi
# ── Template is HTTPS URL ──
n_tests=$((n_tests + 1))
case "$template" in
https://*)
pass "$base: template is HTTPS URL"
;;
*)
fail "$base: template not HTTPS: $template"
;;
esac
# ── Required field: bootstrap_dns ──
n_tests=$((n_tests + 1))
bootstrap="$(python3 -c "import json,sys; d=json.load(open('$f')); print(d.get('bootstrap_dns',''))")"
if [ -n "$bootstrap" ]; then
pass "$base: has bootstrap_dns"
else
fail "$base: missing 'bootstrap_dns' field"
continue
fi
# ── Bootstrap DNS contains valid IPs ──
n_tests=$((n_tests + 1))
bad_ip=""
IFS=',' read -ra ips <<< "$bootstrap"
for ip in "${ips[@]}"; do
ip="$(echo "$ip" | xargs)" # trim whitespace
# Accept IPv4 or IPv6
if echo "$ip" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then
continue
elif echo "$ip" | grep -q ':'; then
continue
else
bad_ip="$ip"
break
fi
done
if [ -z "$bad_ip" ]; then
pass "$base: bootstrap_dns IPs valid"
else
fail "$base: invalid bootstrap IP: '$bad_ip'"
fi
# ── Template params consistency ──
# If template has {placeholders}, params must define them
placeholders="$(echo "$template" | grep -oP '\{(\w+)\}' | sed 's/[{}]//g' | sort -u)"
if [ -n "$placeholders" ]; then
n_tests=$((n_tests + 1))
missing_param=""
for ph in $placeholders; do
has_param="$(python3 -c "
import json,sys
d=json.load(open('$f'))
p=d.get('params',{})
print('yes' if '$ph' in p else 'no')
")"
if [ "$has_param" != "yes" ]; then
missing_param="$ph"
break
fi
done
if [ -z "$missing_param" ]; then
pass "$base: template params match placeholders"
else
fail "$base: template placeholder '{$missing_param}' has no params entry"
fi
# ── Select params have options ──
for ph in $placeholders; do
param_type="$(python3 -c "
import json,sys
d=json.load(open('$f'))
p=d.get('params',{}).get('$ph',{})
print(p.get('type',''))
")"
if [ "$param_type" = "select" ]; then
n_tests=$((n_tests + 1))
opt_count="$(python3 -c "
import json,sys
d=json.load(open('$f'))
p=d.get('params',{}).get('$ph',{})
opts=p.get('options',[])
print(len(opts))
")"
if [ "$opt_count" -gt 0 ]; then
pass "$base: param '$ph' (select) has $opt_count options"
else
fail "$base: param '$ph' (select) has no options"
fi
# ── Default matches one of the options ──
n_tests=$((n_tests + 1))
default_valid="$(python3 -c "
import json,sys
d=json.load(open('$f'))
p=d.get('params',{}).get('$ph',{})
opts=p.get('options',[])
dflt=p.get('default','')
values=[o.get('value','') for o in opts]
print('yes' if dflt in values else 'no')
")"
if [ "$default_valid" = "yes" ]; then
pass "$base: param '$ph' default is a valid option"
else
fail "$base: param '$ph' default not in options list"
fi
fi
done
fi
done
# ── No duplicate titles ──
n_tests=$((n_tests + 1))
titles="$(python3 -c "
import json, os, sys
titles = []
for f in sorted(os.listdir('$PROVIDERS_DIR')):
if f.endswith('.json'):
with open(os.path.join('$PROVIDERS_DIR', f)) as fh:
d = json.load(fh)
titles.append(d.get('title',''))
dupes = [t for t in titles if titles.count(t) > 1]
if dupes:
print('DUPES: ' + ', '.join(set(dupes)))
else:
print('OK')
")"
if [ "$titles" = "OK" ]; then
pass "no duplicate provider titles ($provider_count providers)"
else
fail "duplicate provider titles: $titles"
fi
###############################################################################
# 02: JavaScript template functions #
###############################################################################
printf "\n##\n## 02: JavaScript template functions\n##\n\n"
if command -v node >/dev/null 2>&1; then
# Run tests via Node.js
js_result="$(node --eval "
// Extract the two pure functions from status.js
const pkg = {
templateToRegexp: function (template) {
if (template)
return new RegExp(
'^' +
template
.split(/(\{\w+\})/g)
.map((part) => {
let placeholder = part.match(/^\{(\w+)\}$/);
if (placeholder) return '(?<' + placeholder[1] + '>.*?)';
else return part.replace(/[.*+?\${}()|[\]\\\\]/g, '\\\\' + '\$&');
})
.join('') +
'\$'
);
return new RegExp('');
},
templateToResolver: function (template, args) {
if (template) return template.replace(/{(\w+)}/g, (_, v) => args[v]);
return null;
},
};
let pass = 0, fail = 0, total = 0;
function assert(desc, cond) {
total++;
if (cond) { console.log(' PASS: ' + desc); pass++; }
else { console.log(' FAIL: ' + desc); fail++; }
}
// ── templateToRegexp ──
// Fixed URL (no placeholders)
let re1 = pkg.templateToRegexp('https://dns.google/dns-query');
assert('templateToRegexp: fixed URL matches itself',
re1.test('https://dns.google/dns-query'));
assert('templateToRegexp: fixed URL rejects different URL',
!re1.test('https://dns.other/dns-query'));
// URL with placeholder
let re2 = pkg.templateToRegexp('https://{option}cloudflare-dns.com/dns-query');
assert('templateToRegexp: Cloudflare template matches standard',
re2.test('https://cloudflare-dns.com/dns-query'));
assert('templateToRegexp: Cloudflare template matches family',
re2.test('https://family.cloudflare-dns.com/dns-query'));
assert('templateToRegexp: Cloudflare template matches security',
re2.test('https://security.cloudflare-dns.com/dns-query'));
assert('templateToRegexp: Cloudflare extracts option group',
re2.exec('https://family.cloudflare-dns.com/dns-query').groups.option === 'family.');
// NextDNS text param
let re3 = pkg.templateToRegexp('https://dns.nextdns.io/{option}');
assert('templateToRegexp: NextDNS matches with username',
re3.test('https://dns.nextdns.io/abc123'));
assert('templateToRegexp: NextDNS extracts username',
re3.exec('https://dns.nextdns.io/myuser').groups.option === 'myuser');
assert('templateToRegexp: NextDNS matches empty option',
re3.test('https://dns.nextdns.io/'));
// Mullvad with multiple options
let re4 = pkg.templateToRegexp('https://{option}dns.mullvad.net/dns-query');
assert('templateToRegexp: Mullvad matches standard',
re4.test('https://dns.mullvad.net/dns-query'));
assert('templateToRegexp: Mullvad matches adblock variant',
re4.test('https://adblock.dns.mullvad.net/dns-query'));
// Empty/null template
let re5 = pkg.templateToRegexp('');
assert('templateToRegexp: empty template returns fallback regex',
re5 instanceof RegExp);
let re6 = pkg.templateToRegexp(null);
assert('templateToRegexp: null template returns fallback regex',
re6 instanceof RegExp);
// ── templateToResolver ──
assert('templateToResolver: fills Cloudflare standard',
pkg.templateToResolver('https://{option}cloudflare-dns.com/dns-query', {option: ''})
=== 'https://cloudflare-dns.com/dns-query');
assert('templateToResolver: fills Cloudflare family',
pkg.templateToResolver('https://{option}cloudflare-dns.com/dns-query', {option: 'family.'})
=== 'https://family.cloudflare-dns.com/dns-query');
assert('templateToResolver: fills NextDNS username',
pkg.templateToResolver('https://dns.nextdns.io/{option}', {option: 'myuser'})
=== 'https://dns.nextdns.io/myuser');
assert('templateToResolver: fixed URL unchanged',
pkg.templateToResolver('https://dns.google/dns-query', {})
=== 'https://dns.google/dns-query');
assert('templateToResolver: null template returns null',
pkg.templateToResolver(null, {}) === null);
// ── Round-trip: templateToResolver → templateToRegexp match ──
let providers = [
{template: 'https://{option}cloudflare-dns.com/dns-query', args: {option: 'family.'}},
{template: 'https://{option}cloudflare-dns.com/dns-query', args: {option: ''}},
{template: 'https://dns.nextdns.io/{option}', args: {option: 'abc123'}},
{template: 'https://dns.google/dns-query', args: {}},
{template: 'https://{option}dns.mullvad.net/dns-query', args: {option: 'adblock.'}},
];
for (let p of providers) {
let url = pkg.templateToResolver(p.template, p.args);
let re = pkg.templateToRegexp(p.template);
assert('round-trip: ' + url + ' matches its template',
re.test(url));
}
// Summary line for the shell runner to parse
console.log('JS_SUMMARY:' + total + ':' + pass + ':' + fail);
" 2>&1)"
echo "$js_result" | grep -v '^JS_SUMMARY:'
# Parse the summary
js_total="$(echo "$js_result" | grep '^JS_SUMMARY:' | cut -d: -f2)"
js_pass="$(echo "$js_result" | grep '^JS_SUMMARY:' | cut -d: -f3)"
js_fail="$(echo "$js_result" | grep '^JS_SUMMARY:' | cut -d: -f4)"
n_tests=$((n_tests + js_total))
n_fails=$((n_fails + js_fail))
else
echo " SKIP: node.js not available, skipping JS template tests"
fi
###############################################################################
# 03: RPC backend validation #
###############################################################################
printf "\n##\n## 03: RPC backend validation\n##\n\n"
if [ -f "$RPC_SCRIPT" ]; then
# ── Shell syntax check ──
n_tests=$((n_tests + 1))
if bash -n "$RPC_SCRIPT" 2>/dev/null; then
pass "RPC script passes syntax check"
else
fail "RPC script has syntax errors"
fi
# ── list method returns valid JSON structure ──
n_tests=$((n_tests + 1))
# The 'list' command doesn't need stdin or OpenWrt deps
# We need to provide stubs for the sourced files
list_output="$(
# Provide stubs for OpenWrt libraries
IPKG_INSTROOT="$PWD/tests/stubs"
mkdir -p "$IPKG_INSTROOT/lib" "$IPKG_INSTROOT/usr/share/libubox"
cat > "$IPKG_INSTROOT/lib/functions.sh" << 'STUB'
#!/bin/sh
STUB
cat > "$IPKG_INSTROOT/usr/share/libubox/jshn.sh" << 'STUB'
#!/bin/sh
# Minimal jshn stub for list command
__json_items=""
__json_cur=""
__json_depth=0
json_init() { __json_items=""; __json_cur=""; __json_depth=0; }
json_add_object() {
if [ $__json_depth -eq 0 ]; then
__json_cur="\"$1\":{"
else
__json_cur="${__json_cur}\"$1\":{"
fi
__json_depth=$((__json_depth+1))
}
json_add_string() { __json_cur="${__json_cur}\"$1\":\"$2\","; }
json_close_object() {
__json_cur="${__json_cur%,}}"
__json_depth=$((__json_depth-1))
if [ $__json_depth -eq 0 ]; then
__json_items="${__json_items:+${__json_items},}${__json_cur}"
__json_cur=""
else
__json_cur="${__json_cur},"
fi
}
json_dump() { echo "{${__json_items}}"; }
json_cleanup() { __json_items=""; __json_cur=""; }
STUB
bash -c "
IPKG_INSTROOT='$IPKG_INSTROOT'
. '$RPC_SCRIPT' list
" 2>/dev/null
)"
if echo "$list_output" | python3 -m json.tool >/dev/null 2>&1; then
pass "RPC 'list' returns valid JSON"
else
fail "RPC 'list' does not return valid JSON" "$list_output"
fi
# ── list method declares all expected methods ──
for method in getInitList getInitStatus getPlatformSupport getProviders setInitAction; do
n_tests=$((n_tests + 1))
if echo "$list_output" | grep -q "\"$method\""; then
pass "RPC 'list' declares method '$method'"
else
fail "RPC 'list' missing method '$method'"
fi
done
# ── setInitAction validates service name ──
n_tests=$((n_tests + 1))
if grep -q '\[ "$(basename "$1")" = "$packageName" \]' "$RPC_SCRIPT"; then
pass "setInitAction validates service name"
else
fail "setInitAction missing service name validation"
fi
# ── setInitAction only allows safe actions ──
n_tests=$((n_tests + 1))
if grep -q 'enable|disable|start|stop|restart)' "$RPC_SCRIPT"; then
pass "setInitAction whitelist: enable|disable|start|stop|restart"
else
fail "setInitAction missing action whitelist"
fi
else
echo " SKIP: RPC script not found at $RPC_SCRIPT"
fi
###############################################################################
# SHELL SCRIPT SYNTAX #
###############################################################################
printf "\n--- Shell script syntax ---\n"
for shellscript in \
root/etc/uci-defaults/*; do
[ -f "$shellscript" ] || continue
head -1 "$shellscript" | grep -q '^#!/bin/sh' || continue
name="${shellscript#root/}"
n_tests=$((n_tests + 1))
if sh -n "$shellscript" 2>/dev/null; then
pass "sh -n $name"
else
fail "sh -n $name" "$(sh -n "$shellscript" 2>&1)"
fi
done
###############################################################################
# SUMMARY #
###############################################################################
printf "\nRan %d tests, %d passed, %d failed\n" $n_tests $((n_tests - n_fails)) $n_fails
exit $n_fails