mirror of
https://github.com/YunoHost/package_check.git
synced 2024-09-03 20:06:20 +02:00
Rework test that app can be accessed ... add a new syntax in tests.toml to declare what URL to test, with/without being logged in, and what code/title/content to expect
This commit is contained in:
parent
f9d42bf379
commit
988ec3cf39
3 changed files with 246 additions and 141 deletions
29
README.md
29
README.md
|
@ -187,7 +187,7 @@ test_format = 1.0
|
|||
# -------------------------------
|
||||
# Default args to use for install
|
||||
# -------------------------------
|
||||
|
||||
|
||||
# By default, the CI will automagically fill the 'standard' args
|
||||
# such as domain, path, admin, is_public and password with relevant values
|
||||
# and also install args with a "default" provided in the manifest..
|
||||
|
@ -203,13 +203,38 @@ test_format = 1.0
|
|||
test_upgrade_from.00a1a6e7.name = "Upgrade from 5.4"
|
||||
test_upgrade_from.00a1a6e7.args.foo = "bar"
|
||||
|
||||
# -------------------------------
|
||||
# Curl tests to validate that the app works
|
||||
# -------------------------------
|
||||
[default.curl_tests]
|
||||
#home.path = "/"
|
||||
home.expect_title = "Login - Nextcloud"
|
||||
|
||||
#dash.path = "/"
|
||||
dash.logged_on_sso = true
|
||||
dash.expect_title = "Tableau de bord - Nextcloud"
|
||||
|
||||
admin.path = "/settings/admin"
|
||||
admin.logged_on_sso = true
|
||||
admin.expect_title = "Paramètres d'administration - Nextcloud"
|
||||
|
||||
asset.path = "/core/img/logo/logo.svg"
|
||||
|
||||
file.path = "/remote.php/dav/files/__USER__/Readme.md"
|
||||
file.logged_on_sso = true
|
||||
file.expect_content = "# Welcome to Nextcloud!"
|
||||
|
||||
caldav.base_url = "https://yolo.test"
|
||||
caldav.path = "/.well-known/caldav"
|
||||
caldav.logged_on_sso = true
|
||||
caldav.expect_content = "This is the WebDAV interface."
|
||||
|
||||
# This is an additional test suite
|
||||
[multisite]
|
||||
|
||||
# On additional tests suites, you can decide to run only specific tests
|
||||
|
||||
only = ["install.subdir"]
|
||||
only = ["install.subdir"]
|
||||
|
||||
args.language = "en_GB"
|
||||
args.multisite = 1
|
||||
|
|
194
lib/curl_tests.py
Normal file
194
lib/curl_tests.py
Normal file
|
@ -0,0 +1,194 @@
|
|||
import os
|
||||
import sys
|
||||
import toml
|
||||
import time
|
||||
import re
|
||||
import tempfile
|
||||
import pycurl
|
||||
from bs4 import BeautifulSoup
|
||||
from urllib.parse import urlencode
|
||||
from io import BytesIO
|
||||
|
||||
DOMAIN = os.environ["DOMAIN"]
|
||||
SUBDOMAIN = os.environ["SUBDOMAIN"]
|
||||
USER = os.environ["USER"]
|
||||
PASSWORD = os.environ["PASSWORD"]
|
||||
LXC_IP = os.environ["LXC_IP"]
|
||||
BASE_URL = os.environ["BASE_URL"].rstrip("/")
|
||||
|
||||
DEFAULTS = {
|
||||
"base_url": BASE_URL,
|
||||
"path": "/",
|
||||
"logged_on_sso": False,
|
||||
"expect_title": None,
|
||||
"expect_content": None,
|
||||
"expect_title": None,
|
||||
"expect_effective_url": None,
|
||||
}
|
||||
|
||||
# Example of expected conf:
|
||||
# ==============================================
|
||||
# #home.path = "/"
|
||||
# home.expect_title = "Login - Nextcloud"
|
||||
#
|
||||
# #dash.path = "/"
|
||||
# dash.logged_on_sso = true
|
||||
# dash.expect_title = "Tableau de bord - Nextcloud"
|
||||
#
|
||||
# admin.path = "/settings/admin"
|
||||
# admin.logged_on_sso = true
|
||||
# admin.expect_title = "Paramètres d'administration - Nextcloud"
|
||||
#
|
||||
# asset.path = "/core/img/logo/logo.svg"
|
||||
#
|
||||
# file.path = "/remote.php/dav/files/__USER__/Readme.md"
|
||||
# file.logged_on_sso = true
|
||||
# file.expect_content = "# Welcome to Nextcloud!"
|
||||
#
|
||||
# caldav.base_url = "https://yolo.test"
|
||||
# caldav.path = "/.well-known/caldav"
|
||||
# caldav.logged_on_sso = true
|
||||
# caldav.expect_content = "This is the WebDAV interface."
|
||||
# ==============================================
|
||||
|
||||
|
||||
def curl(base_url, path, method="GET", use_cookies=None, save_cookies=None, post=None):
|
||||
|
||||
domain = base_url.replace("https://", "").replace("http://", "").split("/")[0]
|
||||
|
||||
c = pycurl.Curl() # curl
|
||||
c.setopt(c.URL, f"{base_url}{path}") # https://domain.tld/foo/bar
|
||||
c.setopt(c.FOLLOWLOCATION, True) # --location
|
||||
c.setopt(c.SSL_VERIFYPEER, False) # --insecure
|
||||
c.setopt(c.RESOLVE, [f"{DOMAIN}:80:{LXC_IP}", f"{DOMAIN}:443:{LXC_IP}", f"{SUBDOMAIN}:80:{LXC_IP}", f"{SUBDOMAIN}:443:{LXC_IP}"]) # --resolve
|
||||
c.setopt(c.HTTPHEADER, [f"Host: {domain}", "X-Requested-With: libcurl"]) # --header
|
||||
if use_cookies:
|
||||
c.setopt(c.COOKIEFILE, use_cookies)
|
||||
if save_cookies:
|
||||
c.setopt(c.COOKIEJAR, save_cookies)
|
||||
if post:
|
||||
c.setopt(c.POSTFIELDS, urlencode(post))
|
||||
buffer = BytesIO()
|
||||
c.setopt(c.WRITEDATA, buffer)
|
||||
c.perform()
|
||||
|
||||
effective_url = c.getinfo(c.EFFECTIVE_URL)
|
||||
return_code = c.getinfo(c.RESPONSE_CODE)
|
||||
|
||||
try:
|
||||
return_content = buffer.getvalue().decode()
|
||||
except UnicodeDecodeError:
|
||||
return_content = "(Binary content?)"
|
||||
|
||||
c.close()
|
||||
|
||||
return (return_code, return_content, effective_url)
|
||||
|
||||
|
||||
def test(base_url, path, post=None, logged_on_sso=False, expect_return_code=200, expect_content=None, expect_title=None, expect_effective_url=None):
|
||||
if logged_on_sso:
|
||||
cookies = tempfile.NamedTemporaryFile().name
|
||||
domain = base_url.replace("https://", "").replace("http://", "").split("/")[0]
|
||||
code, content, _ = curl(f"https://{domain}/yunohost/portalapi", "/login", save_cookies=cookies, post={"credentials": f"{USER}:{PASSWORD}"})
|
||||
assert code == 200 and content == "Logged in", f"Failed to log in: got code {code} and content: {content}"
|
||||
else:
|
||||
cookies = None
|
||||
|
||||
code = None
|
||||
retried = 0
|
||||
while code is None or code in {502, 503, 504}:
|
||||
time.sleep(retried * 5)
|
||||
code, content, effective_url = curl(base_url, path, post=post, use_cookies=cookies)
|
||||
retried += 1
|
||||
if retried > 3:
|
||||
break
|
||||
|
||||
try:
|
||||
title = BeautifulSoup(content, features="lxml").find("title").string
|
||||
title = title.strip().replace("\u2013", "-")
|
||||
except Exception:
|
||||
title = ""
|
||||
|
||||
content = BeautifulSoup(content, features="lxml").find("body").get_text().strip()
|
||||
content = re.sub(r"[\t\n\s]{3,}", "\n\n", content)
|
||||
|
||||
errors = []
|
||||
if expect_effective_url is None and "/yunohost/sso" in effective_url:
|
||||
errors.append(f"The request was redirected to yunohost's portal ({effective_url})")
|
||||
if expect_effective_url and expect_effective_url != effective_url:
|
||||
errors.append(f"Ended up on URL '{effective_url}', but was expecting '{expect_effective_url}'")
|
||||
if expect_return_code and code != expect_return_code:
|
||||
errors.append(f"Got return code {code}, but was expecting {expect_return_code}")
|
||||
if expect_title is None and "Welcome to nginx" in title:
|
||||
errors.append("The request ended up on the default nginx page?")
|
||||
if expect_title and not re.search(expect_title, title):
|
||||
errors.append(f"Got title '{title}', but was expecting something containing '{expect_title}'")
|
||||
if expect_content and not re.search(expect_content, content):
|
||||
errors.append(f"Did not find pattern '{expect_content}' in the page content: '{content[:50]}' (on URL {effective_url})")
|
||||
|
||||
return {
|
||||
"url": f"{base_url}{path}",
|
||||
"effective_url": effective_url,
|
||||
"code": code,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def run(tests):
|
||||
|
||||
results = {}
|
||||
|
||||
for name, params in tests.items():
|
||||
full_params = DEFAULTS.copy()
|
||||
full_params.update(params)
|
||||
for key, value in full_params.items():
|
||||
if isinstance(value, str):
|
||||
full_params[key] = value.replace("__USER__", USER)
|
||||
|
||||
results[name] = test(**full_params)
|
||||
display_result(results[name])
|
||||
|
||||
if full_params["path"] == "/":
|
||||
full_params["path"] = ""
|
||||
results[name + "_noslash"] = test(**full_params)
|
||||
display_result(results[name])
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def display_result(result):
|
||||
print(f"URL : {result['url']}")
|
||||
print(f"Real URL: {result['effective_url']}")
|
||||
print(f"Code : {result['code']}")
|
||||
print(f"Title : {result['title'].strip()}")
|
||||
print(f"Content extract:\n{result['content'][:250].strip()}")
|
||||
if result["errors"]:
|
||||
print("Errors :\n -" + "\n -".join(result['errors']))
|
||||
print("\033[1m\033[91mFAIL\033[0m")
|
||||
else:
|
||||
print("\033[1m\033[92mOK\033[0m")
|
||||
print("========")
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
tests = sys.stdin.read()
|
||||
|
||||
if not tests:
|
||||
tests = "home.path = '/'"
|
||||
|
||||
tests = toml.loads(tests)
|
||||
results = run(tests)
|
||||
|
||||
# If there was at least one error 50x
|
||||
if any(str(r['code']).startswith("5") for r in results.values()):
|
||||
sys.exit(5)
|
||||
elif any(r["errors"] for r in results.values()):
|
||||
sys.exit(1)
|
||||
else:
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
main()
|
164
lib/tests.sh
164
lib/tests.sh
|
@ -212,19 +212,15 @@ _REMOVE_APP () {
|
|||
|
||||
_VALIDATE_THAT_APP_CAN_BE_ACCESSED () {
|
||||
|
||||
local check_domain="$1"
|
||||
local check_path="$2"
|
||||
local install_type="$3" # Can be anything or 'private', later used to check if it's okay to end up on the portal
|
||||
local app_id_to_check="${4:-$app_id}"
|
||||
|
||||
local curl_error=0
|
||||
local fell_on_sso_portal=0
|
||||
local curl_output=$TEST_CONTEXT/curl_output
|
||||
|
||||
# Not checking this if this ain't relevant for the current app
|
||||
this_is_a_web_app || return 0
|
||||
|
||||
log_small_title "Validating that the app $app_id_to_check can/can't be accessed with its URL..."
|
||||
# We don't check the private case anymore because meh
|
||||
[[ "$3" != "private" ]] || return 0
|
||||
|
||||
local domain_to_check="$1"
|
||||
local path_to_check="$2"
|
||||
local app_id_to_check="${4:-$app_id}"
|
||||
|
||||
# Force the app to public only if we're checking the public-like installs AND visitors are allowed to access the app
|
||||
# For example, that's the case for agendav which is always installed as
|
||||
|
@ -233,148 +229,38 @@ _VALIDATE_THAT_APP_CAN_BE_ACCESSED () {
|
|||
# accessible *without* tweaking main permission...
|
||||
local has_public_arg=$(LXC_EXEC "cat /etc/ssowat/conf.json" | jq .permissions.\""$app_id_to_check.main"\".public)
|
||||
|
||||
if [ "$install_type" != 'private' ] && [[ $has_public_arg == "false" ]]
|
||||
if [[ $has_public_arg == "false" ]]
|
||||
then
|
||||
log_debug "Forcing public access using tools shell"
|
||||
# Force the public access by setting force=True, which is not possible with "yunohost user permission update"
|
||||
_RUN_YUNOHOST_CMD "tools shell -c 'from yunohost.permission import user_permission_update; user_permission_update(\"$app_id_to_check.main\", add=\"visitors\", force=True)'"
|
||||
fi
|
||||
|
||||
# Try to access to the URL in 2 times, with a final / and without
|
||||
for i in $(seq 1 2)
|
||||
do
|
||||
log_small_title "Validating that the app $app_id_to_check can/can't be accessed with its URL..."
|
||||
|
||||
# First time we'll try without the trailing slash,
|
||||
# Second time *with* the trailing slash
|
||||
local curl_check_path="$(echo $check_path | sed 's@/$@@g')"
|
||||
[ $i -eq 1 ] || curl_check_path="$curl_check_path/"
|
||||
|
||||
# Remove the previous curl output
|
||||
rm -f "$curl_output"
|
||||
python3 -c "import toml, sys; t = toml.loads(sys.stdin.read()); print(toml.dumps(t['default'].get('curl_tests', {})))" < $package_path/tests.toml > $TEST_CONTEXT/curl_tests.toml
|
||||
|
||||
local http_code="noneyet"
|
||||
DOMAIN="$DOMAIN" \
|
||||
SUBDOMAIN="$SUBDOMAIN" \
|
||||
USER="$TEST_USER" \
|
||||
PASSWORD="SomeSuperStrongPassword" \
|
||||
LXC_IP="$LXC_IP" \
|
||||
BASE_URL="https://$domain_to_check$path_to_check" \
|
||||
python3 lib/curl_tests.py < $TEST_CONTEXT/curl_tests.toml | tee -a "$full_log"
|
||||
|
||||
local retry=0
|
||||
function should_retry() {
|
||||
[ "${http_code}" = "noneyet" ] || [ "${http_code}" = "502" ] || [ "${http_code}" = "503" ] || [ "${http_code}" = "504" ]
|
||||
}
|
||||
curl_result=${PIPESTATUS[0]}
|
||||
|
||||
while [ $retry -lt 3 ] && should_retry;
|
||||
do
|
||||
sleep $(($retry*$retry*$retry + 3))
|
||||
|
||||
log_debug "Running curl $check_domain$curl_check_path"
|
||||
|
||||
# Call cURL to try to access to the URL of the app
|
||||
LXC_EXEC "curl --location --insecure --silent --show-error --cookie /dev/null \
|
||||
--header 'Host: $check_domain' \
|
||||
--resolve $DOMAIN:80:$LXC_IP \
|
||||
--resolve $DOMAIN:443:$LXC_IP \
|
||||
--resolve $SUBDOMAIN:80:$LXC_IP \
|
||||
--resolve $SUBDOMAIN:443:$LXC_IP \
|
||||
--write-out '%{http_code};%{url_effective}\n' \
|
||||
--output './curl_output' \
|
||||
$check_domain$curl_check_path" \
|
||||
> "$TEST_CONTEXT/curl_print"
|
||||
|
||||
LXC_EXEC "cat ./curl_output" > $curl_output
|
||||
|
||||
# Analyze the result of curl command
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
log_error "Connection error..."
|
||||
curl_error=1
|
||||
fi
|
||||
|
||||
http_code=$(cat "$TEST_CONTEXT/curl_print" | cut -d ';' -f1)
|
||||
|
||||
log_debug "HTTP code: $http_code"
|
||||
|
||||
retry=$((retry+1))
|
||||
done
|
||||
|
||||
# Analyze the http code (we're looking for 0xx 4xx 5xx 6xx codes)
|
||||
if [ -n "$http_code" ] && echo "0 4 5 6" | grep -q "${http_code:0:1}"
|
||||
then
|
||||
# If the http code is a 0xx 4xx or 5xx, it's an error code.
|
||||
curl_error=1
|
||||
|
||||
# 401 is "Unauthorized", so is a answer of the server. So, it works!
|
||||
[ "${http_code}" == "401" ] && curl_error=0
|
||||
|
||||
[ $curl_error -eq 1 ] && log_error "The HTTP code shows an error."
|
||||
fi
|
||||
|
||||
# Analyze the output of cURL
|
||||
if [ -e "$curl_output" ]
|
||||
then
|
||||
# Print the title of the page
|
||||
local page_title=$(grep "<title>" "$curl_output" | cut --delimiter='>' --fields=2 | cut --delimiter='<' --fields=1)
|
||||
local page_extract=$(lynx -dump -force_html "$curl_output" | head --lines 20 | tee -a "$full_log")
|
||||
|
||||
# Check if the page title is neither the YunoHost portail or default NGINX page
|
||||
# And check if the "Real URL" is the ynh sso
|
||||
if [ "$page_title" = "YunoHost Portal" ] || (cat $TEST_CONTEXT/curl_print | cut --delimiter=';' --fields=2 | grep -q "/yunohost/sso")
|
||||
then
|
||||
log_debug "The connection attempt fall on the YunoHost portal."
|
||||
fell_on_sso_portal=1
|
||||
# Falling on NGINX default page is an error.
|
||||
elif echo "$page_title" | grep -q "Welcome to nginx"
|
||||
then
|
||||
log_error "The connection attempt fall on NGINX default page."
|
||||
curl_error=1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "Test URL: $check_domain$curl_check_path
|
||||
Real URL: $(cat "$TEST_CONTEXT/curl_print" | cut --delimiter=';' --fields=2)
|
||||
HTTP code: $http_code
|
||||
Page title: $page_title
|
||||
Page extract:\n$page_extract" > $TEST_CONTEXT/curl_result
|
||||
|
||||
[[ $curl_error -eq 0 ]] \
|
||||
&& log_debug "$(cat $TEST_CONTEXT/curl_result)" \
|
||||
|| log_warning "$(cat $TEST_CONTEXT/curl_result)"
|
||||
|
||||
# If we had a 50x error, try to display service info and logs to help debugging
|
||||
if [[ $curl_error -ne 0 ]] && echo "5" | grep -q "${http_code:0:1}"
|
||||
then
|
||||
LXC_EXEC "systemctl --no-pager --all" | grep "$app_id_to_check.*service"
|
||||
for SERVICE in $(LXC_EXEC "systemctl --no-pager -all" | grep -o "$app_id_to_check.*service")
|
||||
do
|
||||
LXC_EXEC "journalctl --no-pager --no-hostname -n 30 -u $SERVICE";
|
||||
done
|
||||
LXC_EXEC "tail -v -n 15 \$(find /var/log/{nginx/,php*,$app_id_to_check} -mmin -3)"
|
||||
fi
|
||||
done
|
||||
|
||||
# Detect the issue alias_traversal, https://github.com/yandex/gixy/blob/master/docs/en/plugins/aliastraversal.md
|
||||
# Create a file to get for alias_traversal
|
||||
echo "<!DOCTYPE html><html><head>
|
||||
<title>alias_traversal test</title>
|
||||
</head><body><h1>alias_traversal test</h1>
|
||||
If you see this page, you have failed the test for alias_traversal issue.</body></html>" \
|
||||
> $TEST_CONTEXT/alias_traversal.html
|
||||
|
||||
$lxc file push $TEST_CONTEXT/alias_traversal.html $LXC_NAME/var/www/html/alias_traversal.html
|
||||
|
||||
curl --location --insecure --silent $check_domain$check_path../html/alias_traversal.html \
|
||||
| grep "title" | grep --quiet "alias_traversal test" \
|
||||
&& log_error "Issue alias_traversal detected! Please see here https://github.com/YunoHost/example_ynh/pull/45 to fix that." \
|
||||
&& SET_RESULT "failure" alias_traversal
|
||||
|
||||
[ "$curl_error" -eq 0 ] || return 1
|
||||
local expected_to_fell_on_portal=""
|
||||
[ "$install_type" == "private" ] && expected_to_fell_on_portal=1 || expected_to_fell_on_portal=0
|
||||
|
||||
if [ "$install_type" == "root" ] || [ "$install_type" == "subdir" ] || [ "$install_type" == "upgrade" ];
|
||||
# If we had a 50x error, try to display service info and logs to help debugging
|
||||
if [[ $curl_result == 5 ]]
|
||||
then
|
||||
log_info "$(cat $TEST_CONTEXT/curl_result)"
|
||||
LXC_EXEC "systemctl --no-pager --all" | grep "$app_id_to_check.*service"
|
||||
for SERVICE in $(LXC_EXEC "systemctl --no-pager -all" | grep -o "$app_id_to_check.*service")
|
||||
do
|
||||
LXC_EXEC "journalctl --no-pager --no-hostname -n 30 -u $SERVICE";
|
||||
done
|
||||
LXC_EXEC "tail -v -n 15 \$(find /var/log/{nginx/,php*,$app_id_to_check} -mmin -3)"
|
||||
fi
|
||||
|
||||
[ $fell_on_sso_portal -eq $expected_to_fell_on_portal ] || return 1
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue