mirror of
https://github.com/openwrt/luci.git
synced 2026-04-15 10:51:51 +00:00
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>
485 lines
15 KiB
Bash
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
|