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
25
README.md
25
README.md
|
@ -203,6 +203,31 @@ test_format = 1.0
|
||||||
test_upgrade_from.00a1a6e7.name = "Upgrade from 5.4"
|
test_upgrade_from.00a1a6e7.name = "Upgrade from 5.4"
|
||||||
test_upgrade_from.00a1a6e7.args.foo = "bar"
|
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
|
# This is an additional test suite
|
||||||
[multisite]
|
[multisite]
|
||||||
|
|
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()
|
150
lib/tests.sh
150
lib/tests.sh
|
@ -212,19 +212,15 @@ _REMOVE_APP () {
|
||||||
|
|
||||||
_VALIDATE_THAT_APP_CAN_BE_ACCESSED () {
|
_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
|
# Not checking this if this ain't relevant for the current app
|
||||||
this_is_a_web_app || return 0
|
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
|
# 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
|
# For example, that's the case for agendav which is always installed as
|
||||||
|
@ -233,111 +229,30 @@ _VALIDATE_THAT_APP_CAN_BE_ACCESSED () {
|
||||||
# accessible *without* tweaking main permission...
|
# accessible *without* tweaking main permission...
|
||||||
local has_public_arg=$(LXC_EXEC "cat /etc/ssowat/conf.json" | jq .permissions.\""$app_id_to_check.main"\".public)
|
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
|
then
|
||||||
log_debug "Forcing public access using tools shell"
|
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"
|
# 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)'"
|
_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
|
fi
|
||||||
|
|
||||||
# Try to access to the URL in 2 times, with a final / and without
|
log_small_title "Validating that the app $app_id_to_check can/can't be accessed with its URL..."
|
||||||
for i in $(seq 1 2)
|
|
||||||
do
|
|
||||||
|
|
||||||
# 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
|
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
|
||||||
rm -f "$curl_output"
|
|
||||||
|
|
||||||
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
|
curl_result=${PIPESTATUS[0]}
|
||||||
function should_retry() {
|
|
||||||
[ "${http_code}" = "noneyet" ] || [ "${http_code}" = "502" ] || [ "${http_code}" = "503" ] || [ "${http_code}" = "504" ]
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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}"
|
if [[ $curl_result == 5 ]]
|
||||||
then
|
then
|
||||||
LXC_EXEC "systemctl --no-pager --all" | grep "$app_id_to_check.*service"
|
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")
|
for SERVICE in $(LXC_EXEC "systemctl --no-pager -all" | grep -o "$app_id_to_check.*service")
|
||||||
|
@ -346,35 +261,6 @@ Page extract:\n$page_extract" > $TEST_CONTEXT/curl_result
|
||||||
done
|
done
|
||||||
LXC_EXEC "tail -v -n 15 \$(find /var/log/{nginx/,php*,$app_id_to_check} -mmin -3)"
|
LXC_EXEC "tail -v -n 15 \$(find /var/log/{nginx/,php*,$app_id_to_check} -mmin -3)"
|
||||||
fi
|
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" ];
|
|
||||||
then
|
|
||||||
log_info "$(cat $TEST_CONTEXT/curl_result)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
[ $fell_on_sso_portal -eq $expected_to_fell_on_portal ] || return 1
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue