Merge pull request 'chore: adopt shared Guiding Light configs, fix conformance gaps (18/34 → 30/34)' (#16) from chore/adopt-guiding-light into main
Sync GitHub / sync (push) Successful in 8s
Tests / test (push) Successful in 1m8s
Push Develop / docker (push) Successful in 6m53s

Reviewed-on: #16
Reviewed-by: Andrew <andrew@digitaladapt.com>
This commit was merged in pull request #16.
This commit is contained in:
2026-09-23 16:40:51 -04:00
74 changed files with 2909 additions and 769 deletions
+465
View File
@@ -0,0 +1,465 @@
#!/usr/bin/env bash
#
# conformance.sh — checks a Symfony project against the shared standard.
#
# Usage:
# conformance.sh --profile=web-app|auth-gateway|api-gateway [--json] [path]
#
# Design rules (GUIDING-LIGHT §8.2):
# 1. It only CHECKS. It never fixes anything. No remediation logic to maintain.
# 2. Checks are ADDED, never removed. The script can only get stricter. If a
# check is wrong, fix the check — don't delete it from a repo.
# 3. Every check names the document section it comes from, so a failure tells
# you WHY the rule exists, not just that you broke it.
#
# Exit codes: 0 = all passed, 1 = at least one failure.
#
# DEPENDENCIES: bash + coreutils + grep for everything except one check.
# `controls-16px-min-css` shells out to css-control-size.py because resolving
# rem/em/font-shorthand units correctly is not something grep can do — and
# getting it wrong silently misses the single most important regression in
# this codebase (vital-pulse's 0.95rem inputs). python3 is present on every
# GitHub/Gitea runner and in the setup-php images, so this is a safe
# dependency; if it is ever missing, that one check is SKIPPED with a warning
# rather than failed, so the rest of the suite still runs.
set -uo pipefail
PROFILE=""
OUTPUT_JSON=false
TARGET=""
# Directory this script lives in, so helper tools can be located regardless of
# the CWD the caller is in (CI runs it from the project root).
CONFORMANCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export CONFORMANCE_DIR
for arg in "$@"; do
case "$arg" in
--profile=*) PROFILE="${arg#*=}" ;;
--json) OUTPUT_JSON=true ;;
-h|--help)
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
-*) echo "Unknown flag: $arg" >&2; exit 2 ;;
*) TARGET="$arg" ;;
esac
done
PROFILE="${PROFILE:-web-app}"
TARGET="${TARGET:-.}"
cd "$TARGET" || { echo "Cannot cd to $TARGET" >&2; exit 2; }
case "$PROFILE" in
web-app|auth-gateway|api-gateway) ;;
*) echo "Invalid --profile: $PROFILE (expected web-app, auth-gateway, or api-gateway)" >&2; exit 2 ;;
esac
PASS=0
FAIL=0
SKIP=0
declare -a FAILURES=()
declare -a SKIPPED=()
# check <id> <description> <doc-section> <test-command...>
# The test command must exit 0 to pass.
check() {
local id="$1" desc="$2" section="$3"
shift 3
if "$@" >/dev/null 2>&1; then
PASS=$((PASS + 1))
$OUTPUT_JSON || printf ' \033[32m✓\033[0m %s\n' "$id"
else
FAIL=$((FAIL + 1))
FAILURES+=("$id|$desc|$section")
$OUTPUT_JSON || printf ' \033[31m✗\033[0m %s — %s [%s]\n' "$id" "$desc" "$section"
fi
}
# check_opt <id> <desc> <section> <prereq-cmd> <real-cmd...>
#
# For checks that depend on optional tooling. If the prerequisite is missing
# the check is reported as SKIPPED — its own state, never a green tick.
# A silently-passing check is the most dangerous outcome here: it makes a repo
# look compliant while the check never actually ran.
check_opt() {
local id="$1" desc="$2" section="$3" prereq="$4"
shift 4
# `prereq` is a SHELL EXPRESSION STRING, evaluated with eval.
#
# It must not be a multi-word command: `$prereq` is a single variable, so
# passing `bash -c '...'` would bind only `bash` and run it bare — and a
# bare `bash` reads stdin and BLOCKS FOREVER. That turns a broken check into
# a hung CI job (which only surfaces when the job timeout kills it).
if ! eval "$prereq" >/dev/null 2>&1; then
SKIP=$((SKIP + 1))
SKIPPED+=("$id|$desc|$section")
$OUTPUT_JSON || printf ' \033[33m–\033[0m %s — SKIPPED (prerequisite unavailable) [%s]\n' "$id" "$section"
return
fi
check "$id" "$desc" "$section" "$@"
}
# has_file <path>
has_file() { [ -f "$1" ]; }
# not_has_file <path>
not_has_file() { [ ! -f "$1" ]; }
# file_contains <path> <pattern> (silently false if path missing)
file_contains() { [ -f "$1" ] && grep -qE "$2" "$1"; }
# any_file_contains <pattern> <path...>
#
# NOTE: matches anywhere in the file, INCLUDING comments. That is correct for
# rules about the mere presence of a string, and WRONG for rules about a
# directive. When checking a directive ("CI calls X"), anchor the pattern to
# the line form that the directive actually takes — see ci-reusable-workflows
# below for why that matters.
any_file_contains() {
local pat="$1"; shift
grep -rlE "$pat" "$@" >/dev/null 2>&1
}
# no_file_contains <ext-glob> <pattern> — searches source trees only
no_match_in_sources() {
local pattern="$1"; shift
grep -rEl "$pattern" "$@" >/dev/null 2>&1 && return 1 || return 0
}
# dir_exists
dir_exists() { [ -d "$1" ]; }
$OUTPUT_JSON || {
echo ""
echo "Conformance check — profile: $PROFILE — $(pwd)"
echo "Standard: GUIDING-LIGHT.md"
echo ""
}
# ─────────────────────────────────────────────────────────────────────────────
# UNIVERSAL — every repo, every archetype
# ─────────────────────────────────────────────────────────────────────────────
$OUTPUT_JSON || echo "PHP & framework baseline"
check "php-85" \
"composer.json requires PHP 8.5 (use ^8.5, not >=8.4)" \
"§1.1" \
file_contains composer.json '"php"[[:space:]]*:[[:space:]]*"\^8\.5'
check "php-not-open-ended" \
"PHP constraint is not open-ended (>=8.4 allows PHP 9)" \
"§1.1" \
bash -c '! grep -qE "\"php\"[[:space:]]*:[[:space:]]*\">=" composer.json'
check "platform-pinned" \
"config.platform is set in composer.json (prevents silent version drift)" \
"§1.2" \
file_contains composer.json '"platform"'
check "symfony-81" \
"Symfony pinned to 8.1" \
"§1" \
file_contains composer.json 'symfony/framework-bundle":[[:space:]]*"8\.1\.'
$OUTPUT_JSON || echo ""
$OUTPUT_JSON || echo "Toolchain"
check "phpstan-config" \
"phpstan.neon.dist present" \
"§2.2" \
has_file phpstan.neon.dist
check "phpstan-level" \
"PHPStan level >= 6 declared" \
"§2.2" \
bash -c 'grep -qE "level:[[:space:]]*[6-9]|level:[[:space:]]*max" phpstan.neon.dist 2>/dev/null'
check "cs-fixer-config" \
".php-cs-fixer.dist.php present" \
"§2.5" \
has_file .php-cs-fixer.dist.php
check "cs-fixer-pinned" \
"friendsofphp/php-cs-fixer pinned to ^3.95 (not \"*\")" \
"§2.5" \
bash -c '! grep -qE "php-cs-fixer\"[[:space:]]*:[[:space:]]*\"\*\"" composer.json'
check "phpunit-config-name" \
"PHPUnit config named phpunit.dist.xml" \
"§2.4" \
has_file phpunit.dist.xml
check "editorconfig" \
".editorconfig present" \
"§8.10" \
has_file .editorconfig
$OUTPUT_JSON || echo ""
$OUTPUT_JSON || echo "CI & supply chain"
check "ci-composer-audit" \
"CI runs 'composer audit'" \
"§8.1" \
any_file_contains 'composer audit' .gitea/workflows
check "ci-coverage" \
"CI measures test coverage" \
"§2.3" \
any_file_contains 'coverage' .gitea/workflows
check "ci-composer-validate" \
"CI runs 'composer validate --strict'" \
"§8.3" \
any_file_contains 'composer validate' .gitea/workflows
# Anchored to a REAL `uses:` line, not the string anywhere in the file.
#
# The previous form matched the string anywhere, which gave it a false-positive
# mode that made it worse than useless: a repo that INLINED the shared workflow
# still carries a header comment saying it was "inlined from
# private/ci/.gitea/workflows/...", so the check went green in exactly the repos
# that had drifted. It reported compliance precisely where compliance was absent.
#
# `uses:` is what the description always claimed to test.
check "ci-reusable-workflows" \
"CI calls shared workflows from private/ci (not five drift surfaces)" \
"§8.2" \
any_file_contains '^[[:space:]]*uses:[[:space:]]*private/ci/\.gitea/workflows' .gitea/workflows
$OUTPUT_JSON || echo ""
$OUTPUT_JSON || echo "Hygiene & layout"
check "dockerignore-env" \
".env is excluded in .dockerignore (prevents secrets in images)" \
"§6.1" \
file_contains .dockerignore '^/?\.env$'
check "dockerignore-present" \
".dockerignore present" \
"§6" \
has_file .dockerignore
check "gitignore-var" \
"/var/ excluded in .gitignore" \
"§5.2" \
file_contains .gitignore '^/?var/?$'
check "dockerfile-nonroot" \
"Dockerfile drops privileges with USER" \
"§6.4" \
file_contains Dockerfile '^USER '
check "dockerfile-pinned-base" \
"Dockerfile base image is not :latest" \
"§6.4" \
bash -c '! grep -qE "^FROM[^ ]*:latest" Dockerfile'
check "docs-examples" \
"docs/examples/ present" \
"§4.4" \
dir_exists docs/examples
check "boilerplate-security" \
"SECURITY.md present" \
"§7.1" \
has_file SECURITY.md
check "license-file" \
"LICENSE file present" \
"§7.2" \
has_file LICENSE
check "license-mit" \
"LICENSE is MIT (uniform MIT decided §7.2)" \
"§7.2" \
bash -c 'head -1 LICENSE 2>/dev/null | grep -qi "^MIT License"'
# A composer.json license that contradicts the shipped LICENSE file is worse
# than declaring none: tooling trusts the metadata, humans read the file.
# task-weaver declared "proprietary" while shipping no file at all.
check "license-declared-matches" \
"composer.json declares MIT, matching the LICENSE file" \
"§7.2" \
file_contains composer.json '"license"[[:space:]]*:[[:space:]]*"MIT"'
check "no-cdn-references" \
"No CDN script/link references (zero-CDN goal)" \
"§3.5" \
bash -c '! grep -rElE "(cdn\.jsdelivr|cdnjs\.cloudflare|unpkg\.com|cdn\.tailwindcss)" templates public assets config 2>/dev/null | head -1 | grep -q .'
# ─────────────────────────────────────────────────────────────────────────────
# NOT API-GATEWAY — anything that renders HTML
# ─────────────────────────────────────────────────────────────────────────────
if [ "$PROFILE" != "api-gateway" ]; then
$OUTPUT_JSON || echo ""
$OUTPUT_JSON || echo "Accessibility (§3.3a) — the iOS zoom root cause"
check "viewport-not-zoom-locked" \
"Viewport does not disable pinch-zoom (WCAG 1.4.4)" \
"§3.3a" \
bash -c '! grep -rElE "(user-scalable=no|maximum-scale=1)" templates public 2>/dev/null | head -1 | grep -q .'
check "viewport-fit-cover" \
"Viewport declares viewport-fit=cover (safe areas)" \
"§3.3a" \
bash -c 'grep -rElE "viewport-fit=cover" templates public 2>/dev/null | head -1 | grep -q .'
# Two checks, because there are two ways to get this wrong and one grep
# cannot see both:
# (a) raw CSS with a small font-size — needs unit resolution (rem/em/shorthand)
# (b) Tailwind-style utility classes on the control — not CSS at all
# Requires python3 AND the vendored helper. If either is absent this is
# SKIPPED (reported as its own state), never a green tick.
check_opt "controls-16px-min-css" \
"No form control renders below 16px (root cause of iOS auto-zoom)" \
"§3.3a" \
'command -v python3 >/dev/null 2>&1 && [ -f "$CONFORMANCE_DIR/css-control-size.py" ]' \
bash -c '
mapfile -t files < <(find templates assets public -type f \( -name "*.css" -o -name "*.twig" -o -name "*.html" \) 2>/dev/null)
[ "${#files[@]}" -eq 0 ] && exit 0
exec "$CONFORMANCE_DIR/css-control-size.py" 16 "${files[@]}"
'
check "controls-16px-min-utility" \
"No small text utility class on a form control (Tailwind text-xs/text-sm)" \
"§3.3a" \
bash -c '
! grep -rPzoE "<(input|select|textarea)[^>]*class=\"[^\"]*(text-xs|text-sm)[^\"]*\"" templates 2>/dev/null | head -c1 | grep -q .
'
check "security-headers" \
"Security headers configured (Symfony listener or Caddy)" \
"§8.9" \
bash -c 'grep -rElE "X-Content-Type-Options" src config docker Caddyfile 2>/dev/null | head -1 | grep -q .'
check "no-deprecated-xss-header" \
"X-XSS-Protection not used (deprecated)" \
"§8.9" \
bash -c '! grep -rElE "X-XSS-Protection" src config docker Caddyfile 2>/dev/null | head -1 | grep -q .'
fi
# ─────────────────────────────────────────────────────────────────────────────
# WEB-APP ONLY — installable PWA surface
# ─────────────────────────────────────────────────────────────────────────────
if [ "$PROFILE" = "web-app" ]; then
$OUTPUT_JSON || echo ""
$OUTPUT_JSON || echo "PWA (§3.3b)"
check "webmanifest" \
"Web app manifest present" \
"§3.3b" \
bash -c 'ls public/*.webmanifest public/manifest.json 2>/dev/null | head -1 | grep -q .'
check "manifest-display-standalone" \
"Manifest declares display: standalone" \
"§3.3b" \
bash -c 'grep -qE "\"display\"[[:space:]]*:[[:space:]]*\"standalone\"" public/*.webmanifest public/manifest.json 2>/dev/null'
check "manifest-start-url" \
"Manifest declares start_url" \
"§3.3b" \
bash -c 'grep -qE "\"start_url\"" public/*.webmanifest public/manifest.json 2>/dev/null'
check "service-worker" \
"Service worker present (required for installability)" \
"§3.3b" \
bash -c 'ls public/sw.js public/service-worker.js 2>/dev/null | head -1 | grep -q .'
check "theme-color" \
"theme-color meta present (status bar theming)" \
"§3.3a" \
bash -c 'grep -rElE "theme-color" templates public 2>/dev/null | head -1 | grep -q .'
check "touch-action" \
"touch-action: manipulation used (removes 300ms tap delay)" \
"§3.3a" \
bash -c 'grep -rElE "touch-action" templates assets public 2>/dev/null | head -1 | grep -q .'
fi
# ─────────────────────────────────────────────────────────────────────────────
# AUTH-GATEWAY ONLY — preauth-specific security invariants
# ─────────────────────────────────────────────────────────────────────────────
if [ "$PROFILE" = "auth-gateway" ]; then
$OUTPUT_JSON || echo ""
$OUTPUT_JSON || echo "Auth gateway security (§3.3d)"
check "no-service-worker" \
"Auth gateway has NO service worker (must never replay a cached session)" \
"§3.3d" \
bash -c '! ls public/sw.js public/service-worker.js 2>/dev/null | head -1 | grep -q .'
check "no-store-present" \
"no-store cache directive present somewhere (login flow anti-cache guard)" \
"§3.3d" \
bash -c 'grep -rEli "no-store" src config 2>/dev/null | head -1 | grep -q .'
check "rate-limiter" \
"symfony/rate-limiter required" \
"§8.11" \
file_contains composer.json 'rate-limiter'
fi
# ─────────────────────────────────────────────────────────────────────────────
# REPORT
# ─────────────────────────────────────────────────────────────────────────────
TOTAL=$((PASS + FAIL + SKIP))
# JSON strings must be escaped. Without this, a description containing a double
# quote (e.g. 'pinned to ^3.95 (not "*")') produces invalid JSON and silently
# breaks every consumer of --json.
json_escape() {
local s="$1"
s="${s//\\/\\\\}" # backslash first
s="${s//\"/\\\"}" # then double quote
s="${s//$'\n'/\\n}"
s="${s//$'\t'/\\t}"
s="${s//$'\r'/}"
printf '%s' "$s"
}
if $OUTPUT_JSON; then
printf '{"profile":"%s","passed":%d,"failed":%d,"skipped":%d,"total":%d,"failures":[' \
"$(json_escape "$PROFILE")" "$PASS" "$FAIL" "$SKIP" "$TOTAL"
first=true
for f in "${FAILURES[@]:-}"; do
[ -z "$f" ] && continue
IFS='|' read -r fid fdesc fsec <<< "$f"
$first || printf ','
printf '{"id":"%s","description":"%s","section":"%s"}' \
"$(json_escape "$fid")" "$(json_escape "$fdesc")" "$(json_escape "$fsec")"
first=false
done
printf '],"skipped_checks":['
first=true
for f in "${SKIPPED[@]:-}"; do
[ -z "$f" ] && continue
IFS='|' read -r fid fdesc fsec <<< "$f"
$first || printf ','
printf '{"id":"%s","description":"%s","section":"%s"}' \
"$(json_escape "$fid")" "$(json_escape "$fdesc")" "$(json_escape "$fsec")"
first=false
done
printf ']}\n'
else
echo ""
echo "─────────────────────────────────────────────"
if [ "$SKIP" -gt 0 ]; then
printf ' \033[33m%d of %d checks SKIPPED\033[0m (missing prerequisites — NOT a pass):\n' "$SKIP" "$TOTAL"
for f in "${SKIPPED[@]}"; do
IFS='|' read -r fid fdesc fsec <<< "$f"
printf ' · [%s] %s\n' "$fsec" "$fid"
done
echo ""
fi
if [ "$FAIL" -eq 0 ]; then
printf ' \033[32mAll %d runnable checks passed.\033[0m\n' "$((TOTAL - SKIP))"
else
printf ' \033[31m%d of %d checks failed.\033[0m\n\n' "$FAIL" "$TOTAL"
echo " Guidance:"
for f in "${FAILURES[@]}"; do
IFS='|' read -r fid fdesc fsec <<< "$f"
printf ' • [%s] %s\n %s\n' "$fsec" "$fdesc" "$fid"
done
fi
echo "─────────────────────────────────────────────"
echo ""
fi
[ "$FAIL" -eq 0 ] && exit 0 || exit 1
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""
css-control-size.py — finds form controls whose rendered font-size is below 16px.
This is the precise version of the check. A naive `grep font-size` on the source
produces both false negatives and false positives:
FALSE NEGATIVE: vital-pulse declares `font-size: 0.95rem` on its inputs.
0.95rem x 16px = 15.2px — under the iOS auto-zoom threshold
— but it never appears as a `px` literal.
FALSE POSITIVE: task-weaver has many `font-size: 11px` rules for table
headers and labels. Those are fine. It's *form controls*
that matter, so only rules whose selector targets a control
should be judged.
FALSE POSITIVE: preauth declares `button, input { font-size: 0.9em }`, which
looks small as an `em` value — but its root is
`html { font-size: 1.5em }` = 24px, so the real rendered
size is 21.6px. `em` must be resolved against the parsed root.
So this walks CSS rules, keeps only control-targeting selectors, resolves
px / rem / em to a pixel value, and reports anything under the threshold.
Usage: css-control-size.py <threshold-px> <file> [file...]
Output: one line per violation: "<file>:<line-ish>\t<selector>\t<raw>\t<computed-px>"
Exit: 0 = clean, 1 = violations found
"""
from __future__ import annotations
import re
import sys
# Selectors that style an actual interactive form control.
CONTROL_RE = re.compile(
r"""(?ix)
(?:^|[\s,>+~\[.(#]) # boundary
(?:
input | select | textarea | button
| \.input\b | \.form-control\b | \.btn\b
| \[type= | \.tag-input\b | \.schedule-number\b
)
""",
)
# font-size: 14px | font-size: 0.95rem | font-size: 0.9em
FONT_SIZE_RE = re.compile(
r"font-size\s*:\s*(?P<val>[0-9]*\.?[0-9]+)\s*(?P<unit>px|rem|em|pt)\b",
re.IGNORECASE,
)
# font: 14px/1.5 ... (shorthand — sets font-size implicitly)
FONT_SHORTHAND_RE = re.compile(
r"font\s*:\s*(?:[^;{}]*?\s)?(?P<val>[0-9]*\.?[0-9]+)\s*(?P<unit>px|rem|em|pt)\b",
re.IGNORECASE,
)
# html { font-size: 1.5em } — establishes the em base.
ROOT_SELECTOR_RE = re.compile(r"(?i)^\s*(?:html|:root)\s*$")
DEFAULT_ROOT_PX = 16.0
PT_TO_PX = 4.0 / 3.0
def strip_comments(css: str) -> str:
return re.sub(r"/\*.*?\*/", " ", css, flags=re.DOTALL)
def parse_rules(css: str):
"""Yield (selector, body, index) for every rule, including nested ones."""
css = strip_comments(css)
stack: list[str] = []
acc = ""
for i, ch in enumerate(css):
if ch == "{":
stack.append(acc.strip())
acc = ""
elif ch == "}":
selector = stack.pop() if stack else ""
if selector:
# Body is what accumulated inside this rule; recover it from
# the source between the opening brace and here.
yield selector, "", i
acc = ""
else:
acc += ch
def parse_rules_with_bodies(css: str):
"""Yield (selector, body). Handles nesting (@media) by tracking depth."""
css = strip_comments(css)
stack: list[str] = []
out: list[tuple[str, str]] = []
acc = ""
body_start: list[int] = []
for i, ch in enumerate(css):
if ch == "{":
stack.append(acc.strip())
body_start.append(i + 1)
acc = ""
elif ch == "}":
if stack:
selector = stack.pop()
start = body_start.pop() if body_start else 0
out.append((selector, css[start:i]))
acc = ""
else:
acc += ch
return out
def resolve_px(value: float, unit: str, root_px: float) -> float:
unit = unit.lower()
if unit == "px":
return value
if unit == "rem":
return value * DEFAULT_ROOT_PX
if unit == "em":
# Resolved against the root that we parsed. This is an approximation
# (true `em` is parent-relative) but it is correct for the real case
# that matters: a page-level `html { font-size: N }` scaling controls.
return value * root_px
if unit == "pt":
return value * PT_TO_PX
return value
def find_root_px(rules) -> float:
"""Find an explicit html/:root font-size to use as the em base."""
for selector, body in rules:
sel = selector.split(",")[0].strip()
if ROOT_SELECTOR_RE.match(sel):
m = FONT_SIZE_RE.search(body)
if m:
val = float(m.group("val"))
unit = m.group("unit")
# Root em is relative to the 16px default.
return resolve_px(val, unit, DEFAULT_ROOT_PX)
return DEFAULT_ROOT_PX
def check_file(path: str, threshold: float) -> list[tuple[str, str, str, float]]:
violations: list[tuple[str, str, str, float]] = []
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
css = fh.read()
except OSError:
return violations
# Only look at things that plausibly contain CSS.
if "{" not in css:
return violations
rules = parse_rules_with_bodies(css)
root_px = find_root_px(rules)
for selector, body in rules:
# Skip at-rule wrappers; their inner rules are yielded separately.
head = selector.strip()
if head.startswith("@"):
continue
# A rule may have several comma-separated selectors; judge each.
for one in head.split(","):
one = one.strip()
if not one:
continue
is_inherited_base = bool(re.match(r"(?i)^(?:html|body)$", one))
targets_control = bool(CONTROL_RE.search(one))
if not targets_control and not is_inherited_base:
continue
# Ignore rules that only set colours etc. — we want font-size.
m = FONT_SIZE_RE.search(body)
if m:
val = float(m.group("val"))
unit = m.group("unit")
raw = f"{m.group('val')}{unit}"
elif is_inherited_base:
# `body { font: 14px/1.5 }` sets the base every unstyled control
# inherits. Only the shorthand carries a size here.
m2 = FONT_SHORTHAND_RE.search(body)
if not m2:
continue
val = float(m2.group("val"))
unit = m2.group("unit")
raw = f"font-shorthand {m2.group('val')}{unit}"
else:
# A control-targeting rule with no size of its own inherits
# whatever body provides, which is reported separately.
continue
computed = resolve_px(val, unit, root_px)
if computed < threshold:
violations.append((path, one, raw, computed))
return violations
def main() -> int:
if len(sys.argv) < 3:
print(__doc__, file=sys.stderr)
return 2
try:
threshold = float(sys.argv[1])
except ValueError:
print(f"threshold must be a number, got {sys.argv[1]!r}", file=sys.stderr)
return 2
total = 0
for path in sys.argv[2:]:
for vpath, selector, raw, computed in check_file(path, threshold):
print(f"{vpath}\t{selector}\t{raw}\t{computed:.1f}px")
total += 1
if total:
print(
f"\n{total} control style(s) render below {threshold:.0f}px. "
"iOS Safari auto-zooms any focused control under 16px — this is the "
"trigger that `user-scalable=no` was masking (GUIDING-LIGHT §3.3a).",
file=sys.stderr,
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+41 -5
View File
@@ -1,20 +1,56 @@
# editorconfig.org
#
# Canonical shared .editorconfig. Copy verbatim into a project root.
# LEAF file: sync = overwrite, never merge (GUIDING-LIGHT §8.2).
#
# context-loom was missing this entirely in the 2026-09 audit; the other four
# had three subtly different versions.
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
# YAML is indentation-significant and the rest of the ecosystem uses 2 spaces.
[*.{yaml,yml}]
indent_size = 2
# Docker/compose files follow the same convention.
[{Dockerfile,*.dockerfile}]
indent_size = 4
[{compose.yaml,compose.*.yaml,compose.yml}]
indent_size = 2
[*.json]
indent_size = 2
# Markdown: trailing whitespace is a hard line break in some renderers, so
# stripping it silently changes formatting.
[*.md]
trim_trailing_whitespace = false
# Generated / vendored content: never touch, even accidentally on save.
[{vendor/**,var/**,node_modules/**,public/bundles/**}]
insert_final_newline = false
trim_trailing_whitespace = false
# Caddy's own formatter (caddy fmt) indents with tabs, and every Caddyfile in
# the portfolio already uses them — at the root (preauth, penny-track,
# vital-pulse), under docker/ (the FrankenPHP app config), and under
# docs/examples/ (the edge-proxy reference copied from). Without this rule the
# `[*]` block above silently tells editors to use spaces, so every save
# reindents the file and caddy fmt immediately undoes it.
[Caddyfile]
indent_style = tab
[{compose.yaml,compose.*.yaml}]
indent_size = 2
[Makefile]
indent_style = tab
[*.md]
trim_trailing_whitespace = false
[*.{sh,bash}]
indent_size = 4
+68 -13
View File
@@ -1,18 +1,73 @@
<?php
$finder = (new PhpCsFixer\Finder())
->in(__DIR__)
->exclude('var')
->exclude('vendor')
->notPath([
'config/bundles.php',
'config/reference.php',
])
;
declare(strict_types=1);
return (new PhpCsFixer\Config())
/**
* .php-cs-fixer.dist.php — canonical shared config.
*
* Copy verbatim into a project root. LEAF file: sync = overwrite, never merge.
* Change it here and re-sync; do not hand-edit per repo (GUIDING-LIGHT §8.2).
*
* This replaces three divergent versions found in the 2026-09 audit:
* context-loom — had @Symfony + risky + declare_strict_types
* penny-track / preauth / vital-pulse — a second variant
* task-weaver — a third variant
*
* Pin friendsofphp/php-cs-fixer to ^3.95 in composer.json. preauth was on
* "*", which means its CI was not reproducible.
*/
$config = new PhpCsFixer\Config();
return $config
->setRiskyAllowed(true)
->setRules([
'@PSR12' => true,
'@Symfony' => true,
'@Symfony:risky' => true,
// Unambiguous wins.
'declare_strict_types' => true,
'no_unused_imports' => true,
'ordered_imports' => [
'sort_algorithm' => 'alpha',
'imports_order' => ['class', 'function', 'const'],
],
'php_unit_method_casing' => ['case' => 'snake_case'],
// Trailing commas in multiline constructs keep diffs to one line when
// a parameter is appended — reviewable, and no reformat noise.
'trailing_comma_in_multiline' => [
'elements' => ['arrays', 'arguments', 'parameters', 'match'],
],
// `array()` → `[]`, consistent with everything else in these repos.
'array_syntax' => ['syntax' => 'short'],
// Group imports so a file's dependency surface is scannable.
'global_namespace_import' => [
'import_classes' => true,
'import_constants' => false,
'import_functions' => false,
],
// Keep `#[Attribute]`-style attributes on their own line for long ones.
'attribute_empty_parentheses' => true,
])
->setFinder($finder)
;
->setFinder(
(new PhpCsFixer\Finder())
->in(__DIR__)
->exclude('vendor')
->exclude('var')
->exclude('node_modules')
// Migration classes are generated and version-stamped upstream;
// reformatting them makes diffs against the generator noisy.
->notPath('src/Migrations')
// Symfony's config reference is regenerated by `cache:clear`, which
// composer runs on every install — so it is present in CI even
// though it is gitignored. Formatting it makes the fixer report a
// file the author cannot commit, and a fresh `cache:clear`
// immediately undoes the fix, so CI can never go green.
->notPath('config/reference.php')
->ignoreDotFiles(true)
->ignoreVCS(true)
);
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 digitaladapt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+69
View File
@@ -0,0 +1,69 @@
# Security Policy
## Supported Versions
| Version | Supported |
|---------|-----------|
| unreleased (v1 development) | ✅ |
## Reporting a Vulnerability
Report vulnerabilities privately to **security@digitaladapt.com** (or open a private
security advisory on the repository). Please include reproduction steps and affected
versions. You will receive an acknowledgement within 48 hours and a status update at
least weekly until resolution.
**Do not open a public issue for a suspected vulnerability.** preauth is an
authentication gateway — it sits in front of every protected service, so a
weakness here is a weakness everywhere behind it.
## Security model summary
preauth implements the auth half of the `forward_auth` pattern: a reverse proxy
calls it per request to decide whether a request may reach the upstream service.
- **Two outcomes per request: allow or intercept.** `AcceptListener` /
`RejectListener` / `InterceptListener` decide, and the decision is made on
every request rather than cached — a cached auth session is an anti-pattern
(GUIDING-LIGHT §3.3d), which is also why this project gets **no service
worker**.
- **The login flow is never cached.** The login page, failed logins, redirects
and rate-limit responses are sent with
`Cache-Control: no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0`.
An aggressive cache (notably older Safari) replaying a stale pre-auth response
presents to the user as being logged back out after a refresh.
- **Headers are set by the app, not left to the proxy.** `X-Content-Type-Options:
nosniff`, `X-Frame-Options: DENY`, a Content-Security-Policy, and
`Strict-Transport-Security: max-age=31536000`. Docs recommend mirroring the
caching headers at the edge as defence in depth, but the app does not depend
on it.
- **TOTP is required.** Secrets come from `TOTP_URI`; if it is unset the app
generates one and prints it for enrolment. Login state is carried in a signed
payload (`src/Data/Payload.php`) bound to a nonce and a scope, not in a
server-side session store.
- **Rate limiting is on by default**, with the block response configurable
(`TEAPOT=false` returns 429 rather than 418).
- **`REMOTE_USER` is trusted input, not a secret.** In `remote_user` modes the
gateway accepts an upstream-asserted identity, so the upstream must be the
only path to the app. Do not expose preauth directly to the internet for this
mode.
- **`.env` is never committed; secrets are env vars injected at runtime.** Real
secrets belong in `.env.local` or `bin/console secrets:set`, read via
`%env(...)%`. `.env.example` and `.env.test` are the committed env files.
## Scope
In scope: the application code in `src/`, the shipped `Caddyfile`, the
`Dockerfile`, and anything that affects the allow/intercept decision.
Out of scope: the `forward_auth` integration at the edge (a host-proxy
configuration concern, see `docs/examples/Caddyfile`) and the security of the
services preauth protects.
## Deployment note
preauth runs as a container and, once
[GUIDING-LIGHT §6.4](https://code.devgnome.com/private/ci) is adopted here, will
drop privileges via `USER`. The image declares `VOLUME ["/config", "/data"]`;
if you pin a `user:` in your compose file, that user must be able to write both
paths — otherwise login state and backup codes cannot be persisted.
+7 -3
View File
@@ -4,7 +4,7 @@
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.4",
"php": "^8.5",
"ext-ctype": "*",
"ext-iconv": "*",
"bacon/bacon-qr-code": "^3.1.1",
@@ -27,7 +27,10 @@
"symfony/runtime": true
},
"bump-after-update": true,
"sort-packages": true
"sort-packages": true,
"platform": {
"php": "8.5.0"
}
},
"autoload": {
"psr-4": {
@@ -71,7 +74,8 @@
}
},
"require-dev": {
"friendsofphp/php-cs-fixer": "*",
"friendsofphp/php-cs-fixer": "^3.95",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^13.2",
"symfony/browser-kit": "8.1.*",
"symfony/css-selector": "8.1.*"
Generated
+69 -2
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "a0c760a2f38ab5185750b58a0cf9be20",
"content-hash": "e057ec8918177382ec6fe101350d6b86",
"packages": [
{
"name": "bacon/bacon-qr-code",
@@ -4443,6 +4443,70 @@
},
"time": "2022-02-21T01:04:05+00:00"
},
{
"name": "phpstan/phpstan",
"version": "2.2.15",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/b158556ffd26825cf615a1c1f72fb5157a2301b7",
"reference": "b158556ffd26825cf615a1c1f72fb5157a2301b7",
"shasum": ""
},
"require": {
"php": "^7.4|^8.0"
},
"conflict": {
"phpstan/phpstan-shim": "*"
},
"bin": [
"phpstan",
"phpstan.phar"
],
"type": "library",
"autoload": {
"files": [
"bootstrap.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ondřej Mirtes"
},
{
"name": "Markus Staab"
},
{
"name": "Vincent Langlet"
}
],
"description": "PHPStan - PHP Static Analysis Tool",
"keywords": [
"dev",
"static analysis"
],
"support": {
"docs": "https://phpstan.org/user-guide/getting-started",
"forum": "https://github.com/phpstan/phpstan/discussions",
"issues": "https://github.com/phpstan/phpstan/issues",
"security": "https://github.com/phpstan/phpstan/security/policy",
"source": "https://github.com/phpstan/phpstan-src"
},
"funding": [
{
"url": "https://github.com/ondrejmirtes",
"type": "github"
},
{
"url": "https://github.com/phpstan",
"type": "github"
}
],
"time": "2026-09-23T12:23:07+00:00"
},
{
"name": "phpunit/php-code-coverage",
"version": "14.3.2",
@@ -7081,10 +7145,13 @@
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": ">=8.4",
"php": "^8.5",
"ext-ctype": "*",
"ext-iconv": "*"
},
"platform-dev": {},
"platform-overrides": {
"php": "8.5.0"
},
"plugin-api-version": "2.9.0"
}
+2
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
+4 -2
View File
@@ -1,8 +1,10 @@
<?php
if (file_exists(dirname(__DIR__) .
declare(strict_types=1);
if (file_exists(dirname(__DIR__).
'/var/cache/prod/App_KernelProdContainer.preload.php')
) {
require dirname(__DIR__) .
require dirname(__DIR__).
'/var/cache/prod/App_KernelProdContainer.preload.php';
}
@@ -1,7 +1,7 @@
services:
preauth:
env_file:
# rename "example.env" to ".env", edit as needed
# copy ".env.example" to ".env", edit as needed
# strongly recommend setting TOTP_URI, if not provided the app
# will generate one for you, please copy it into your .env file
- .env
+985
View File
@@ -0,0 +1,985 @@
parameters:
ignoreErrors:
-
message: '#^Method App\\Clock\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Clock.php
-
message: '#^Method App\\Command\\GenerateBackupCodesCommand\:\:configure\(\) overrides method Symfony\\Component\\Console\\Command\\Command\:\:configure\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Command/GenerateBackupCodesCommand.php
-
message: '#^Method App\\Command\\GenerateBackupCodesCommand\:\:execute\(\) overrides method Symfony\\Component\\Console\\Command\\Command\:\:execute\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Command/GenerateBackupCodesCommand.php
-
message: '#^Class App\\Data\\Payload has an uninitialized property \$id\. Give it default value or assign it in the constructor\.$#'
identifier: property.uninitialized
count: 1
path: src/Data/Payload.php
-
message: '#^Class App\\Data\\Payload has an uninitialized property \$json\. Give it default value or assign it in the constructor\.$#'
identifier: property.uninitialized
count: 1
path: src/Data/Payload.php
-
message: '#^Class App\\Data\\Payload has an uninitialized property \$nonce\. Give it default value or assign it in the constructor\.$#'
identifier: property.uninitialized
count: 1
path: src/Data/Payload.php
-
message: '#^Class App\\Data\\Payload has an uninitialized property \$scope\. Give it default value or assign it in the constructor\.$#'
identifier: property.uninitialized
count: 1
path: src/Data/Payload.php
-
message: '#^Class App\\Data\\Payload has an uninitialized property \$token\. Give it default value or assign it in the constructor\.$#'
identifier: property.uninitialized
count: 1
path: src/Data/Payload.php
-
message: '#^Method App\\Data\\Payload\:\:load\(\) has parameter \$input with generic class Symfony\\Component\\HttpFoundation\\InputBag but does not specify its types\: TInput$#'
identifier: missingType.generics
count: 1
path: src/Data/Payload.php
-
message: '#^Class App\\Kernel has an uninitialized property \$persistCache\. Give it default value or assign it in the constructor\.$#'
identifier: property.uninitialized
count: 1
path: src/Kernel.php
-
message: '#^Method App\\Kernel\:\:boot\(\) overrides method Symfony\\Component\\HttpKernel\\Kernel\:\:boot\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Kernel.php
-
message: '#^Method App\\Kernel\:\:terminate\(\) overrides method Symfony\\Component\\HttpKernel\\Kernel\:\:terminate\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Kernel.php
-
message: '#^Class App\\Listener\\AcceptListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Listener/AcceptListener.php
-
message: '#^Readonly property App\\Listener\\AcceptListener\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Listener/AcceptListener.php
-
message: '#^Class App\\Listener\\AllowListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Listener/AllowListener.php
-
message: '#^Readonly property App\\Listener\\AllowListener\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Listener/AllowListener.php
-
message: '#^Class App\\Listener\\InterceptListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Listener/InterceptListener.php
-
message: '#^Class App\\Listener\\InterceptListener has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Listener/InterceptListener.php
-
message: '#^Property App\\Listener\\InterceptListener\:\:\$config is never read, only written\.$#'
identifier: property.onlyWritten
count: 1
path: src/Listener/InterceptListener.php
-
message: '#^Readonly property App\\Listener\\InterceptListener\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 2
path: src/Listener/InterceptListener.php
-
message: '#^Readonly property App\\Listener\\InterceptListener\:\:\$nonceCache is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Listener/InterceptListener.php
-
message: '#^Class App\\Listener\\LoginListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Listener/LoginListener.php
-
message: '#^Class App\\Listener\\LoginListener has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Listener/LoginListener.php
-
message: '#^Readonly property App\\Listener\\LoginListener\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 2
path: src/Listener/LoginListener.php
-
message: '#^Readonly property App\\Listener\\LoginListener\:\:\$nonceCache is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Listener/LoginListener.php
-
message: '#^Using nullsafe property access "\?\-\>id" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: src/Listener/LoginListener.php
-
message: '#^Using nullsafe property access "\?\-\>json" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: src/Listener/LoginListener.php
-
message: '#^Class App\\Listener\\PublicAccessListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Listener/PublicAccessListener.php
-
message: '#^Readonly property App\\Listener\\PublicAccessListener\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Listener/PublicAccessListener.php
-
message: '#^Using nullsafe method call on non\-nullable type DateTimeImmutable\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: src/Listener/PublicAccessListener.php
-
message: '#^Class App\\Listener\\RejectListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Listener/RejectListener.php
-
message: '#^Readonly property App\\Listener\\RejectListener\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Listener/RejectListener.php
-
message: '#^Method App\\MonitorCacheKeys\:\:allValid\(\) has parameter \$keys with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Method App\\MonitorCacheKeys\:\:clear\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:clear\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Method App\\MonitorCacheKeys\:\:commit\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:commit\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Method App\\MonitorCacheKeys\:\:deleteItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:deleteItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Method App\\MonitorCacheKeys\:\:deleteItems\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:deleteItems\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Method App\\MonitorCacheKeys\:\:getChanges\(\) return type has no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Method App\\MonitorCacheKeys\:\:getItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:getItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Method App\\MonitorCacheKeys\:\:getItems\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:getItems\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Method App\\MonitorCacheKeys\:\:getKeys\(\) return type has no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Method App\\MonitorCacheKeys\:\:hasItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:hasItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Method App\\MonitorCacheKeys\:\:save\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:save\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Method App\\MonitorCacheKeys\:\:saveDeferred\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:saveDeferred\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/MonitorCacheKeys.php
-
message: '#^Class App\\Service\\BackupCodeManager has an uninitialized readonly property \$config\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Service/BackupCodeManager.php
-
message: '#^Class App\\Service\\BackupCodeManager has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Service/BackupCodeManager.php
-
message: '#^Method App\\Service\\BackupCodeManager\:\:expire\(\) overrides method App\\Service\\BackupCodeInterface\:\:expire\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Service/BackupCodeManager.php
-
message: '#^Method App\\Service\\BackupCodeManager\:\:generate\(\) overrides method App\\Service\\BackupCodeInterface\:\:generate\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Service/BackupCodeManager.php
-
message: '#^Method App\\Service\\BackupCodeManager\:\:saveCodes\(\) has parameter \$codes with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: src/Service/BackupCodeManager.php
-
message: '#^Method App\\Service\\BackupCodeManager\:\:verifyAndConsume\(\) overrides method App\\Service\\BackupCodeInterface\:\:verifyAndConsume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Service/BackupCodeManager.php
-
message: '#^Readonly property App\\Service\\BackupCodeManager\:\:\$config is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Service/BackupCodeManager.php
-
message: '#^Readonly property App\\Service\\BackupCodeManager\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Service/BackupCodeManager.php
-
message: '#^Method App\\Service\\DomainManager\:\:authBase\(\) overrides method App\\Service\\DomainInterface\:\:authBase\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Service/DomainManager.php
-
message: '#^Method App\\Service\\DomainManager\:\:getAuthSubdomain\(\) overrides method App\\Service\\DomainInterface\:\:getAuthSubdomain\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Service/DomainManager.php
-
message: '#^Method App\\Service\\DomainManager\:\:matchesAuth\(\) overrides method App\\Service\\DomainInterface\:\:matchesAuth\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Service/DomainManager.php
-
message: '#^Method App\\Service\\DomainManager\:\:validReturn\(\) overrides method App\\Service\\DomainInterface\:\:validReturn\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Service/DomainManager.php
-
message: '#^Class App\\Service\\LoginManager has an uninitialized readonly property \$config\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Service/LoginManager.php
-
message: '#^Class App\\Service\\LoginManager has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Service/LoginManager.php
-
message: '#^Class App\\Service\\LoginManager has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Service/LoginManager.php
-
message: '#^Method App\\Service\\LoginManager\:\:checkToken\(\) overrides method App\\Service\\LoginInterface\:\:checkToken\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Service/LoginManager.php
-
message: '#^Readonly property App\\Service\\LoginManager\:\:\$config is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Service/LoginManager.php
-
message: '#^Readonly property App\\Service\\LoginManager\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Service/LoginManager.php
-
message: '#^Readonly property App\\Service\\LoginManager\:\:\$nonceCache is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Service/LoginManager.php
-
message: '#^Method App\\Service\\PublicPathMatcher\:\:isEmpty\(\) overrides method App\\Service\\PublicPathMatcherInterface\:\:isEmpty\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Service/PublicPathMatcher.php
-
message: '#^Method App\\Service\\PublicPathMatcher\:\:matches\(\) overrides method App\\Service\\PublicPathMatcherInterface\:\:matches\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: src/Service/PublicPathMatcher.php
-
message: '#^Method App\\Tests\\Functional\\AuthenticationFlowTest\:\:createClient\(\) has parameter \$options with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Functional/AuthenticationFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\AuthenticationFlowTest\:\:createClient\(\) has parameter \$server with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Functional/AuthenticationFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\AuthenticationFlowTest\:\:createClient\(\) overrides method Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase\:\:createClient\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Functional/AuthenticationFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\AuthenticationFlowTest\:\:encodePayload\(\) has parameter \$data with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Functional/AuthenticationFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\AuthenticationFlowTest\:\:loginPayload\(\) is unused\.$#'
identifier: method.unused
count: 1
path: tests/Functional/AuthenticationFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\CacheControlFlowTest\:\:createClient\(\) has parameter \$options with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Functional/CacheControlFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\CacheControlFlowTest\:\:createClient\(\) has parameter \$server with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Functional/CacheControlFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\CacheControlFlowTest\:\:createClient\(\) overrides method Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase\:\:createClient\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Functional/CacheControlFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\CacheControlFlowTest\:\:encodePayload\(\) has parameter \$data with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Functional/CacheControlFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\PublicAccessFlowTest\:\:createClient\(\) has parameter \$options with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Functional/PublicAccessFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\PublicAccessFlowTest\:\:createClient\(\) has parameter \$server with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Functional/PublicAccessFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\PublicAccessFlowTest\:\:createClient\(\) overrides method Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase\:\:createClient\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Functional/PublicAccessFlowTest.php
-
message: '#^Method App\\Tests\\Functional\\PublicAccessFlowTest\:\:encodePayload\(\) has parameter \$data with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Functional/PublicAccessFlowTest.php
-
message: '#^Method App\\Tests\\TestKernel\:\:build\(\) overrides method Symfony\\Component\\DependencyInjection\\Kernel\\AbstractKernel\:\:build\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/TestKernel.php
-
message: '#^Method Symfony\\Component\\DependencyInjection\\Compiler\\CompilerPassInterface@anonymous/tests/TestKernel\.php\:31\:\:process\(\) overrides method Symfony\\Component\\DependencyInjection\\Compiler\\CompilerPassInterface\:\:process\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/TestKernel.php
-
message: '#^Method App\\Tests\\Unit\\Command\\GenerateBackupCodesCommandTest\:\:makeManagerStub\(\) has parameter \$generatedCodes with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Unit/Command/GenerateBackupCodesCommandTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/ConfigBagRemoteUserTest.php
-
message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with null will always evaluate to true\.$#'
identifier: staticMethod.alreadyNarrowedType
count: 2
path: tests/Unit/Enum/ScopeTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/AcceptListenerTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/AllowListenerTest.php
-
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
identifier: arguments.count
count: 2
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
identifier: arguments.count
count: 2
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method App\\Tests\\Unit\\Listener\\LoginListenerTest\:\:encodePayload\(\) has parameter \$data with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
identifier: arguments.count
count: 2
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
identifier: arguments.count
count: 2
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertIsString\(\) with string will always evaluate to true\.$#'
identifier: staticMethod.alreadyNarrowedType
count: 1
path: tests/Unit/Service/BackupCodeManagerTest.php
-
message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#'
identifier: staticMethod.alreadyNarrowedType
count: 1
path: tests/Unit/Service/BackupCodeManagerTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Service/BackupCodeManagerTest.php
-
message: '#^Call to an undefined method App\\Service\\BackupCodeInterface\:\:method\(\)\.$#'
identifier: method.notFound
count: 17
path: tests/Unit/Service/LoginManagerTest.php
-
message: '#^Class App\\Tests\\Unit\\Service\\LoginManagerTest has an uninitialized property \$backupCodeManager\. Give it default value or assign it in the constructor\.$#'
identifier: property.uninitialized
count: 1
path: tests/Unit/Service/LoginManagerTest.php
-
message: '#^Class App\\Tests\\Unit\\Service\\LoginManagerTest has an uninitialized property \$domainManager\. Give it default value or assign it in the constructor\.$#'
identifier: property.uninitialized
count: 1
path: tests/Unit/Service/LoginManagerTest.php
-
message: '#^Class App\\Tests\\Unit\\Service\\LoginManagerTest has an uninitialized property \$pool\. Give it default value or assign it in the constructor\.$#'
identifier: property.uninitialized
count: 1
path: tests/Unit/Service/LoginManagerTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Service/LoginManagerTest.php
-
message: '#^Class class@anonymous/tests/Unit/Trait/GetTotpTraitTest\.php\:22 has an uninitialized readonly property \$config\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: tests/Unit/Trait/GetTotpTraitTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/GetTotpTraitTest.php
-
message: '#^Readonly property class@anonymous/tests/Unit/Trait/GetTotpTraitTest\.php\:22\:\:\$config is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: tests/Unit/Trait/GetTotpTraitTest.php
-
message: '#^Class App\\Tests\\Unit\\Trait\\HasLoggerTraitTest has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: tests/Unit/Trait/HasLoggerTraitTest.php
-
message: '#^Readonly property App\\Tests\\Unit\\Trait\\HasLoggerTraitTest\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: tests/Unit/Trait/HasLoggerTraitTest.php
-
message: '#^Class class@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:25 has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Class class@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:25 has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:expiresAfter\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:expiresAfter\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:expiresAt\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:expiresAt\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:get\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:get\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:getKey\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:getKey\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:isHit\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:isHit\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:145\:\:set\(\) overrides method Psr\\Cache\\CacheItemInterface\:\:set\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:clear\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:clear\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:commit\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:commit\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:deleteItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:deleteItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:deleteItems\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:deleteItems\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:getItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:getItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:getItems\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:getItems\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:getItems\(\) return type has no value type specified in iterable type iterable\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:hasItem\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:hasItem\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:save\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:save\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Cache\\CacheItemPoolInterface@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:128\:\:saveDeferred\(\) overrides method Psr\\Cache\\CacheItemPoolInterface\:\:saveDeferred\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Readonly property class@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:25\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Readonly property class@anonymous/tests/Unit/Trait/MakeNonceTraitTest\.php\:25\:\:\$nonceCache is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/StringTraitTest.php
-
message: '#^Call to function method_exists\(\) with ''Symfony\\\\Component\\\\Dotenv\\\\Dotenv'' and ''bootEnv'' will always evaluate to false\.$#'
identifier: function.impossibleType
count: 1
path: tests/bootstrap.php
-
message: '#^Call to method bootEnv\(\) on an unknown class Symfony\\Component\\Dotenv\\Dotenv\.$#'
identifier: class.notFound
count: 1
path: tests/bootstrap.php
-
message: '#^Class Symfony\\Component\\Dotenv\\Dotenv not found\.$#'
identifier: class.notFound
count: 1
path: tests/bootstrap.php
-
message: '#^Instantiated class Symfony\\Component\\Dotenv\\Dotenv not found\.$#'
identifier: class.notFound
count: 1
path: tests/bootstrap.php
+116
View File
@@ -0,0 +1,116 @@
# phpstan.neon.dist — canonical shared PHPStan config.
#
# Copy verbatim into a project root. This is a LEAF file: it has no
# project-specific content, so "sync it" means "overwrite it", never merge.
# Do not hand-edit per repo — change it here and re-sync, or the five copies
# drift back apart (GUIDING-LIGHT §8.2).
#
# Baseline: level 6 for application code. Raise per project as it gets clean;
# the goal recorded in GUIDING-LIGHT §2.2 is level 6 minimum everywhere.
#
# Adopt incrementally:
# 1. vendor/bin/phpstan analyse --generate-baseline
# 2. Commit the result over the empty phpstan-baseline.neon that ships with this
# 3. Ratchet `level` up as the baseline shrinks
# Never replace a fix with an ignoreErrors entry — see reportIgnoresWithoutComments.
#
# IMPORTANT — every key below is VERIFIED against phpstan.org/config-reference.
# PHPStan 2.x errors on unknown keys, but a plausible-looking wrong key copied
# from a blog post is a common way to lose an afternoon. If you add a key,
# confirm it there first. Extension-specific keys (symfony.*, doctrine.*,
# phpstan-deprecation-rules, etc.) are deliberately NOT set here — see the
# commented block at the bottom for why and how to opt in per project.
parameters:
level: 6
paths:
- src
- tests
# ── High-signal checks (all verified key names) ──────────────────────────
# An `@var` that contradicts the assignment is almost always a real bug.
reportWrongPhpDocTypeInVarTag: true
# A `@var` that only widens the inferred type is usually an unnecessary cast.
reportAnyTypeWideningInVarTag: true
# `@param`/`@return` that contradict the native signature.
reportStaticMethodSignatures: true
# An ignoreErrors entry with no explanatory comment is a smell.
reportIgnoresWithoutComments: true
# Forces every ignore to still match something. Without this, ignores
# accumulate forever and nobody notices when the underlying bug is fixed.
reportUnmatchedIgnoredErrors: true
# Catch `Foo` vs `foo` in function names — matters for Windows devs and
# for correctness under strict autoloading.
checkFunctionNameCase: true
# Typed properties that are read before they are definitely initialised.
checkUninitializedProperties: true
# Respect #[Override] so refactors in parent classes cannot silently stop
# overriding a method that got renamed.
checkMissingOverrideMethodAttribute: true
checkMissingOverridePropertyAttribute: true
# Static analysis cannot see through sprintf, so mis-ordered placeholders
# are otherwise invisible until runtime.
checkStrictPrintfPlaceholderTypes: true
# Dynamic properties are deprecated in PHP 8.2+ and are a common source of
# typos that would otherwise fail silently at runtime.
checkDynamicProperties: true
# All five repos have this file (verified); the kernel boot lives here.
bootstrapFiles:
- tests/bootstrap.php
ignoreErrors:
# Symfony's createClient() returns KernelBrowser, but some test helpers
# are typed against the narrower legacy interface.
# reportUnmatched:false so this does not fail the build once the
# offending helper is typed properly.
#
# NOTE the layout: the dash sits alone and the keys are indented under
# it. This is the form used verbatim in PHPStan's own documentation.
# (The more compact `- message: ...` / continuation form is also valid
# NEON, but NOT every NEON parser in the wild handles it — the PHP
# parser PHPStan uses handles both, Python's neon-py handles neither
# reliably. Staying with the documented form avoids the argument.)
-
message: '#Call to an undefined method Symfony\\Component\\HttpFoundation\\Session\\SessionInterface::#'
reportUnmatched: false
includes:
# Ships EMPTY with this config. A repo overwrites it when it runs
# --generate-baseline. It must exist: a missing `includes` target is a hard
# error, not a silent skip, which is why the empty file is committed rather
# than the include being made conditional.
- phpstan-baseline.neon
# ─────────────────────────────────────────────────────────────────────────────
# OPTIONAL EXTENSIONS — commented out on purpose.
#
# These keys are owned by PHPStan *extensions*, not core. If the extension is
# not installed, or the key name drifts between extension majors, analysis
# fails outright. So they are opt-in per project rather than shared.
#
# Symfony — resolves service ids, autowiring, and container params from the
# compiled container. Requires phpstan/phpstan-symfony. Uncomment AND make sure
# the path exists (warm the dev cache first, or let the test bootstrap do it).
#
# symfony:
# containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
#
# Doctrine — validates DQL against the actual mapping. Requires
# phpstan/phpstan-doctrine. `repositoryClass` must name a class that EXISTS in
# the project; setting it to a class you do not have is an instant failure.
#
# doctrine:
# repositoryClass: App\Repository\YourBaseRepository
# ─────────────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -6,6 +6,6 @@ use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
return static function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};
+4 -4
View File
@@ -44,7 +44,7 @@ docker pull digitaladapt/preauth:latest
openssl rand -base64 30
```
Create a `.env` file (see `docs/example.env` for all options):
Create a `.env` file (see `docs/examples/.env.example` for all options):
```env
APP_SECRET=your-random-secret-here
@@ -61,7 +61,7 @@ COOKIE_TTL=2592000
docker compose up -d
```
See `docs/compose.yaml` for an example Docker Compose file.
See `docs/examples/compose.yaml` for an example Docker Compose file.
### 4. Configure Caddy
@@ -82,7 +82,7 @@ service.example.com {
}
```
See `docs/Caddyfile` for more examples, including path-specific protection
See `docs/examples/Caddyfile` for more examples, including path-specific protection
and central auth subdomain configuration. The `header_down` lines above are
optional — preauth already sends these headers itself — but they guarantee
at the edge that no part of the login flow is ever cached. (2xx auth
@@ -106,7 +106,7 @@ capabilities may work, but only Caddy is officially supported.
## Configuration
All configuration is via environment variables. See `docs/example.env`
All configuration is via environment variables. See `docs/examples/.env.example`
for the complete reference.
### Main Options
+3 -2
View File
@@ -9,10 +9,10 @@ use App\Service\BackupCodeInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException as ConsoleInvalidArgumentException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Exception\InvalidArgumentException as ConsoleInvalidArgumentException;
/** simple console command to generate backup codes
* usage: php bin/console app:generate-backup-codes [count] */
@@ -21,7 +21,7 @@ final class GenerateBackupCodesCommand extends Command
{
public function __construct(
private readonly BackupCodeInterface $manager,
private readonly PersistCache $persistCache,
private readonly PersistCache $persistCache,
) {
parent::__construct();
}
@@ -46,6 +46,7 @@ final class GenerateBackupCodesCommand extends Command
$output->writeln($code);
}
$this->persistCache->persist();
return Command::SUCCESS;
}
}
+20 -19
View File
@@ -26,31 +26,31 @@ final readonly class ConfigBag
/** @throws InvalidArgumentException */
public function __construct(
Utilities $utilities,
ClockInterface $clock,
#[Autowire('%app.cookie_ttl%')] int $cookieTtl,
#[Autowire('%app.totp_uri%')] string $totpUri,
#[Autowire('%app.ip_ttl%')] ?int $ipTtl,
#[Autowire('%app.teapot%')] bool $teapot,
#[Autowire('%app.error_message%')] string $errorMessage,
#[Autowire('%app.teapot_title%')] string $teapotTitle,
Utilities $utilities,
ClockInterface $clock,
#[Autowire('%app.cookie_ttl%')] int $cookieTtl,
#[Autowire('%app.totp_uri%')] string $totpUri,
#[Autowire('%app.ip_ttl%')] ?int $ipTtl,
#[Autowire('%app.teapot%')] bool $teapot,
#[Autowire('%app.error_message%')] string $errorMessage,
#[Autowire('%app.teapot_title%')] string $teapotTitle,
#[Autowire('%app.too_many_title%')] string $tooManyTitle,
#[Autowire('%app.remote_user%')] string $remoteUserMode,
#[Autowire('%app.remote_user%')] string $remoteUserMode,
#[Autowire('%app.remote_user_static%')] string $remoteUserStatic,
#[Autowire('%app.remote_user_map%')] string $remoteUserMap,
) {
$this->clock = $clock;
$this->cookieTtl = $cookieTtl;
$this->totpUri = $totpUri ?: $utilities->loadTotp();
$this->ipTtl = $ipTtl ?: null;
$this->teapot = $teapot;
$this->clock = $clock;
$this->cookieTtl = $cookieTtl;
$this->totpUri = $totpUri ?: $utilities->loadTotp();
$this->ipTtl = $ipTtl ?: null;
$this->teapot = $teapot;
$this->errorMessage = $errorMessage;
$this->teapotTitle = $teapotTitle;
$this->teapotTitle = $teapotTitle;
$this->tooManyTitle = $tooManyTitle;
$this->remoteUserMode = RemoteUserMode::tryFrom($remoteUserMode) ?? RemoteUserMode::Session;
$this->remoteUserMode = RemoteUserMode::tryFrom($remoteUserMode) ?? RemoteUserMode::Session;
$this->remoteUserStatic = $remoteUserStatic;
$this->remoteUserMap = $this->parseUserMap($remoteUserMap);
$this->remoteUserMap = $this->parseUserMap($remoteUserMap);
}
/**
@@ -60,17 +60,18 @@ final readonly class ConfigBag
*/
private function parseUserMap(string $map): array
{
if ($map === '') {
if ('' === $map) {
return [];
}
$result = [];
foreach (explode(',', $map) as $pair) {
$parts = explode(':', trim($pair), 2);
if (count($parts) === 2) {
if (2 === \count($parts)) {
$result[trim($parts[0])] = trim($parts[1]);
}
}
return $result;
}
+22 -20
View File
@@ -14,59 +14,61 @@ final class Payload
public string $id; /* session name, identifying who is logging in */
public string $token; /* TOTP, typically six digits */
public string $nonce; /* random unique string, to block duplicate submissions */
public bool $json; /* should we return json (for the login page) */
public Scope $scope; /* type of access being requested */
public bool $json; /* should we return json (for the login page) */
public Scope $scope; /* type of access being requested */
public static function decode(string $base64url): ?Payload
public static function decode(string $base64url): ?self
{
/* convert the base64url into json string */
$base64 = strtr($base64url, '-_', '+/');
$base64 .= str_repeat('=', (4 - strlen($base64) % 4) % 4);
$base64 .= str_repeat('=', (4 - \strlen($base64) % 4) % 4);
$json = base64_decode($base64, true);
if ($json) {
/* convert the json string into real data */
$data = json_decode($json);
if (is_object($data)) {
return Payload::create($data);
if (\is_object($data)) {
return self::create($data);
}
}
return null;
}
public static function load(InputBag $input): ?Payload
public static function load(InputBag $input): ?self
{
/* convert form data into real data */
if ($input->has('username') && $input->has('nonce') && $input->has('totp')) {
return Payload::create((object)[
'id' => $input->get('username'),
return self::create((object) [
'id' => $input->get('username'),
'nonce' => $input->get('nonce'),
'token' => $input->get('totp'),
'json' => false,
'json' => false,
]);
}
return null;
}
public static function create(object $data): ?Payload
public static function create(object $data): ?self
{
/* if missing required fields id, nonce, or token */
if (strlen(trim($data->id ?? '')) < 1 ||
strlen(trim($data->nonce ?? '')) < 1 ||
strlen(trim($data->token ?? '')) < 1
if ('' === trim($data->id ?? '')
|| '' === trim($data->nonce ?? '')
|| '' === trim($data->token ?? '')
) {
/* returns null as the input is invalid */
return null;
}
/* all input is limited */
$payload = new Payload();
$payload->id = mb_substr(trim($data->id), 0, AppConstants::MAX_INPUT_LENGTH);
$payload = new self();
$payload->id = mb_substr(trim($data->id), 0, AppConstants::MAX_INPUT_LENGTH);
$payload->nonce = mb_substr(trim($data->nonce), 0, AppConstants::MAX_INPUT_LENGTH);
$payload->json = ($data->json ?? true);
$payload->json = ($data->json ?? true);
$payload->scope = Scope::tryFrom($data->scope ?? '') ?? Scope::Cookie;
$payload->token = mb_substr(trim($data->token), 0, AppConstants::MAX_INPUT_LENGTH);
return Payload::constrict($payload);
return self::constrict($payload);
}
public function toString(): string
@@ -74,10 +76,10 @@ final class Payload
return json_encode($this);
}
private static function constrict(Payload $payload): Payload
private static function constrict(self $payload): self
{
/* When scope is None, json will be considered false. */
if ($payload->scope === Scope::None) {
if (Scope::None === $payload->scope) {
$payload->json = false;
}
+2 -2
View File
@@ -8,6 +8,6 @@ namespace App\Enum;
enum Scope: string
{
case Cookie = 'cookie';
case Ip = 'ip';
case None = 'none';
case Ip = 'ip';
case None = 'none';
}
+5 -5
View File
@@ -22,8 +22,8 @@ final readonly class AcceptListener
public function __construct(
private CacheItemPoolInterface $sessionCache,
private DomainInterface $domainManager,
private ConfigBag $config,
private DomainInterface $domainManager,
private ConfigBag $config,
) {
}
@@ -32,7 +32,7 @@ final readonly class AcceptListener
{
/* check if they sent the correct preauth cookie */
$cookieName = $this->sessionCookieName($this->domainManager);
if (! $event->getRequest()->cookies->has($cookieName)) {
if (!$event->getRequest()->cookies->has($cookieName)) {
return;
}
@@ -40,13 +40,13 @@ final readonly class AcceptListener
$cookieKey = $this->makeCacheKey("cookie_$cookie");
try {
if (! $cookie || ! $this->sessionCache->hasItem($cookieKey)) {
if (!$cookie || !$this->sessionCache->hasItem($cookieKey)) {
return;
}
/* cookie sent corresponds to valid existing session */
$item = $this->sessionCache->getItem($cookieKey);
if (! $item->isHit()) {
if (!$item->isHit()) {
/* race condition: item was removed between hasItem and getItem */
return;
}
+3 -3
View File
@@ -19,7 +19,7 @@ final readonly class AllowListener
public function __construct(
private CacheItemPoolInterface $sessionCache,
private ConfigBag $config,
private ConfigBag $config,
) {
}
@@ -33,13 +33,13 @@ final readonly class AllowListener
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
try {
if (! $this->sessionCache->hasItem($ipKey)) {
if (!$this->sessionCache->hasItem($ipKey)) {
return;
}
/* ip address corresponds to valid existing session */
$item = $this->sessionCache->getItem($ipKey);
if (! $item->isHit()) {
if (!$item->isHit()) {
/* race condition: item was removed between hasItem and getItem */
return;
}
+9 -9
View File
@@ -26,9 +26,9 @@ final readonly class InterceptListener
use MakeNonceTrait;
public function __construct(
private ConfigBag $config,
private ConfigBag $config,
private DomainInterface $domainManager,
private Environment $twig,
private Environment $twig,
) {
}
@@ -39,29 +39,29 @@ final readonly class InterceptListener
/* by this point, we know that the request we have is:
* not already authorized, nor already rate-limited,
* nor submitting login credentials; so redirect or present the login page now */
if ($this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost() &&
$this->domainManager->matchesAuth($event->getRequest()->getHost())
if ($this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost()
&& $this->domainManager->matchesAuth($event->getRequest()->getHost())
) {
/* host matches base-domain of auth, but not on auth subdomain, redirect */
$query = http_build_query(['return' => $event->getRequest()->getUri()]);
$event->setResponse(new Response(
'',
Response::HTTP_SEE_OTHER,
['Location' => "https://{$this->domainManager->getAuthSubdomain()}/?$query"]
['Location' => "https://{$this->domainManager->getAuthSubdomain()}/?$query"],
));
} else {
$this->logger->debug("presenting login page: {$event->getRequest()->getClientIp()}");
$content = $this->twig->render('login.html.twig', [
'nonce' => $this->makeNonce(),
'post' => $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost(),
'post' => $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost(),
]);
$hasCookie = (bool) $event->getRequest()->cookies->get(
$this->sessionCookieName($this->domainManager)
$this->sessionCookieName($this->domainManager),
);
$event->setResponse($this->pruneInvalidCookie(new Response(
$content,
Response::HTTP_UNAUTHORIZED,
['Content-Type' => 'text/html']
['Content-Type' => 'text/html'],
), $hasCookie, $event->getRequest()->getHost()));
}
}
@@ -75,7 +75,7 @@ final readonly class InterceptListener
$this->sessionCookieDomain($this->domainManager, $host),
true,
true,
Cookie::SAMESITE_STRICT
Cookie::SAMESITE_STRICT,
);
}
+18 -16
View File
@@ -43,28 +43,28 @@ final readonly class LoginListener
private RateLimiterFactoryInterface $rateLimiter;
public function __construct(
private Environment $twig,
private Environment $twig,
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
private DomainInterface $domainManager,
private LoginInterface $loginManager,
private ConfigBag $config,
private DomainInterface $domainManager,
private LoginInterface $loginManager,
private ConfigBag $config,
) {
$this->rateLimiter = $rateLimiter;
$this->rateLimiter = $rateLimiter;
}
/** @throws InvalidArgumentException|LoaderError|RuntimeError|SyntaxError */
#[AsEventListener(priority: 66)]
public function onKernelRequest(RequestEvent $event): void
{
$payload = null;
$payload = null;
$response = null;
if ($event->getRequest()->headers->has($this->headerName())) {
/* if request contains our "X-Preauth" header */
$data = $event->getRequest()->headers->get($this->headerName());
$payload = Payload::decode($data);
} elseif ($event->getRequest()->isMethod(Request::METHOD_POST) &&
$this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost()
} elseif ($event->getRequest()->isMethod(Request::METHOD_POST)
&& $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost()
) {
/* if request is a POST to the auth-subdomain */
$payload = Payload::load($event->getRequest()->getPayload());
@@ -80,6 +80,7 @@ final readonly class LoginListener
/* token or backup-code authentication was successful */
if ($response) {
$event->setResponse($response);
return;
}
}
@@ -92,14 +93,15 @@ final readonly class LoginListener
$limitReached,
$payload?->json ?? true,
$event->getRequest()->getHost(),
$this->makeCacheKey($payload?->id ?? '')
$this->makeCacheKey($payload?->id ?? ''),
));
}
private function logFailure(Request $request): bool
{
$limiter = $this->rateLimiter->create($request->getClientIp());
return ($limiter->consume(1)->getRemainingTokens() < 1);
return $limiter->consume(1)->getRemainingTokens() < 1;
}
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
@@ -111,24 +113,24 @@ final readonly class LoginListener
$message = $this->config->teapot() ? $this->config->teapotTitle()
: $this->config->tooManyTitle();
} else {
$status = Response::HTTP_UNAUTHORIZED;
$status = Response::HTTP_UNAUTHORIZED;
$message = $this->config->errorMessage();
}
$answer = [
'message' => $message,
'nonce' => $this->makeNonce(),
'post' => $this->domainManager->getAuthSubdomain() === $host,
'nonce' => $this->makeNonce(),
'post' => $this->domainManager->getAuthSubdomain() === $host,
'username' => $username,
];
if ($json) {
$contentType = 'application/json';
$content = json_encode($answer);
$content = json_encode($answer);
} else {
$contentType = 'text/html';
$content = $this->twig->render('login.html.twig', $answer);
$content = $this->twig->render('login.html.twig', $answer);
}
return new Response($content, $status, ["Content-Type" => $contentType]);
return new Response($content, $status, ['Content-Type' => $contentType]);
}
}
+6 -6
View File
@@ -39,8 +39,8 @@ final readonly class PublicAccessListener
public function __construct(
private PublicPathMatcherInterface $pathMatcher,
private DomainInterface $domainManager,
private Environment $twig,
private DomainInterface $domainManager,
private Environment $twig,
#[Target('public_limiter')] RateLimiterFactoryInterface $rateLimiter,
) {
$this->rateLimiter = $rateLimiter;
@@ -63,7 +63,7 @@ final readonly class PublicAccessListener
return;
}
if (! $this->pathMatcher->matches($host, $path)) {
if (!$this->pathMatcher->matches($host, $path)) {
return;
}
@@ -77,8 +77,8 @@ final readonly class PublicAccessListener
'',
Response::HTTP_OK,
[
'Content-Type' => 'text/plain',
'Retry-After' => (string) $limit->getRemainingTokens(),
'Content-Type' => 'text/plain',
'Retry-After' => (string) $limit->getRemainingTokens(),
],
));
} else {
@@ -92,7 +92,7 @@ final readonly class PublicAccessListener
Response::HTTP_TOO_MANY_REQUESTS,
[
'Content-Type' => 'text/html',
'Retry-After' => (string) $retryAfter,
'Retry-After' => (string) $retryAfter,
],
));
}
+6 -6
View File
@@ -9,8 +9,8 @@ use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Twig\Environment;
use Twig\Error\LoaderError;
@@ -25,8 +25,8 @@ final readonly class RejectListener
private RateLimiterFactoryInterface $rateLimiter;
public function __construct(
private ConfigBag $config,
private Environment $twig,
private ConfigBag $config,
private Environment $twig,
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
) {
$this->rateLimiter = $rateLimiter;
@@ -43,9 +43,9 @@ final readonly class RejectListener
$html = $this->twig->render('error.html.twig');
$event->setResponse(new Response(
$html,
($this->config->teapot()
? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS),
['Content-Type' => 'text/html']
$this->config->teapot()
? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS,
['Content-Type' => 'text/html'],
));
}
}
+4 -4
View File
@@ -24,12 +24,12 @@ final readonly class SecurityHeadersListener
#[AsEventListener(priority: 0)]
public function onKernelResponse(ResponseEvent $event): void
{
if (! $event->isMainRequest()) {
if (!$event->isMainRequest()) {
return;
}
$response = $event->getResponse();
$headers = $response->headers;
$headers = $response->headers;
/* prevent MIME-type sniffing */
$headers->set('X-Content-Type-Options', 'nosniff');
@@ -53,7 +53,7 @@ final readonly class SecurityHeadersListener
* work. On the auth subdomain the form POSTs normally and no
* inline script is included, so the stricter policy applies. */
$inlineScript = $this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost();
$csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';";
$csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';";
if ($inlineScript) {
$csp .= " connect-src 'self';";
@@ -76,7 +76,7 @@ final readonly class SecurityHeadersListener
* reverse proxy's forward_auth check before reaching the browser,
* and the protected service's own cache headers must remain
* untouched. */
if (! $response->isSuccessful()) {
if (!$response->isSuccessful()) {
$headers->set('Cache-Control', 'no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0');
$headers->set('Pragma', 'no-cache');
$headers->set('Expires', '0');
+16 -14
View File
@@ -26,7 +26,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
$this->cache = $cache;
$items = $cache->getItems([self::KEY_LIST, self::CHANGE_LIST]);
foreach ($items as $item) {
if (! $item->isHit()) {
if (!$item->isHit()) {
$this->initialize();
break;
}
@@ -49,6 +49,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
public function getKeys(): array
{
$keyList = $this->cache->getItem(self::KEY_LIST);
return array_keys($keyList->get() ?? []);
}
@@ -56,6 +57,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
public function getChanges(): array
{
$changeList = $this->cache->getItem(self::CHANGE_LIST);
return $changeList->get() ?? [];
}
@@ -90,12 +92,14 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
public function clear(): bool
{
/* only bother clearing the pool if it is not empty */
if (! empty($this->getKeys())) {
if (!empty($this->getKeys())) {
$response = $this->cache->clear();
$this->initialize();
return $response;
}
return true;
}
@@ -109,7 +113,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
unset($keyValues[$key]);
$keyList->set($keyValues);
$this->cache->saveDeferred($keyList);
$this->logChange($key, MonitorCacheKeys::REMOVED);
$this->logChange($key, self::REMOVED);
$this->cache->commit();
}
@@ -125,7 +129,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
foreach ($keys as $key) {
if (isset($keyValues[$key])) {
unset($keyValues[$key]);
$this->logChange($key, MonitorCacheKeys::REMOVED);
$this->logChange($key, self::REMOVED);
}
}
$keyList->set($keyValues);
@@ -139,6 +143,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
public function save(CacheItemInterface $item): bool
{
$this->update($item);
return $this->cache->save($item);
}
@@ -146,6 +151,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
public function saveDeferred(CacheItemInterface $item): bool
{
$this->update($item);
return $this->cache->saveDeferred($item);
}
@@ -171,27 +177,23 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
/** @throws OutOfBoundsException */
private function isValid(string $key): void
{
if ($key === self::KEY_LIST || $key === self::CHANGE_LIST) {
throw new OutOfBoundsException(
'Can not modify the private key or change lists'
);
if (self::KEY_LIST === $key || self::CHANGE_LIST === $key) {
throw new OutOfBoundsException('Can not modify the private key or change lists');
}
}
/** @throws OutOfBoundsException */
private function allValid(array $keys): void
{
if (in_array(self::KEY_LIST, $keys, true) ||
in_array(self::CHANGE_LIST, $keys, true)
if (\in_array(self::KEY_LIST, $keys, true)
|| \in_array(self::CHANGE_LIST, $keys, true)
) {
throw new OutOfBoundsException(
'Can not modify the private key or change lists'
);
throw new OutOfBoundsException('Can not modify the private key or change lists');
}
}
/** @throws InvalidArgumentException */
private function logChange(string $key, int $code = MonitorCacheKeys::UPDATED): void
private function logChange(string $key, int $code = self::UPDATED): void
{
$changeList = $this->cache->getItem(self::CHANGE_LIST);
$changeValues = $changeList->get();
+1 -1
View File
@@ -20,7 +20,7 @@ final readonly class PersistCache
CacheItemPoolInterface $sessionCache,
CacheItemPoolInterface $sessionStorage,
) {
$this->sessionCache = new MonitorCacheKeys($sessionCache);
$this->sessionCache = new MonitorCacheKeys($sessionCache);
$this->sessionStorage = new MonitorCacheKeys($sessionStorage);
}
+6 -2
View File
@@ -11,18 +11,22 @@ use Psr\Cache\InvalidArgumentException;
* they are single-use and marked as used after successful authentication */
interface BackupCodeInterface
{
/** generate a set of backup-codes and return them
/** generate a set of backup-codes and return them.
* @param int $count Number of codes to generate
*
* @return string[] Generated backup codes
*
* @throws InvalidArgumentException|Exception */
public function generate(int $count = 10): array;
/** @throws InvalidArgumentException */
public function expire(): void;
/** check if backup-code is valid and mark it as used
/** check if backup-code is valid and mark it as used.
* @param string $code Code supplied by the client
*
* @return bool true if the code is valid and unused
*
* @throws InvalidArgumentException */
public function verifyAndConsume(string $code): bool;
}
+18 -12
View File
@@ -6,13 +6,13 @@ namespace App\Service;
use App\AppConstants;
use App\MonitorCacheKeys;
use App\Trait\GetTotpTrait;
use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait;
use DateTimeImmutable;
use Exception;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use App\Trait\GetTotpTrait;
/** backup-codes are case‑insensitive alphanumeric strings
* they are single-use and marked as used after successful authentication */
@@ -34,22 +34,25 @@ final readonly class BackupCodeManager implements BackupCodeInterface
$this->sessionCache = new MonitorCacheKeys($sessionCache);
}
/** generate a set of backup-codes and return them
/** generate a set of backup-codes and return them.
* @param int $count Number of codes to generate
*
* @return string[] Generated backup codes
*
* @throws InvalidArgumentException|Exception */
public function generate(int $count = self::DEFAULT_COUNT): array
{
$length = min($this->getTotp()->getDigits() + 2, self::MAX_LENGTH);
$codes = [];
for ($i = 0; $i < $count; $i++) {
for ($i = 0; $i < $count; ++$i) {
/* output is alphanumeric string of given length */
$codes[] = strtolower(str_pad(substr(base_convert(bin2hex(
random_bytes($length)
), 16, 36), 0, $length), $length, '0', STR_PAD_LEFT));
random_bytes($length),
), 16, 36), 0, $length), $length, '0', \STR_PAD_LEFT));
}
$this->saveCodes($codes);
$this->logger->info("generated {$count} backup codes");
return $codes;
}
@@ -62,35 +65,38 @@ final readonly class BackupCodeManager implements BackupCodeInterface
$itemsToRemove[] = $key;
}
}
if (count($itemsToRemove) > 0) {
if (\count($itemsToRemove) > 0) {
$this->sessionCache->deleteItems($itemsToRemove);
}
}
/** check if backup-code is valid and mark it as used
/** check if backup-code is valid and mark it as used.
* @param string $code Code supplied by the client
*
* @return bool true if the code is valid and unused
*
* @throws InvalidArgumentException */
public function verifyAndConsume(string $code): bool
{
/* remove unallowed characters, since backup codes are case-insensitive alphanumeric */
$backupKey = 'backup_' . preg_replace('/[^a-z0-9]+/', '', strtolower($code));
$backupKey = 'backup_'.preg_replace('/[^a-z0-9]+/', '', strtolower($code));
$backupItem = $this->sessionCache->getItem($this->makeCacheKey($backupKey));
$this->logger->debug('checking backup code: ' . ($backupItem->isHit() ? 'HIT & ' : 'miss & ') . ($backupItem->get() ? 'VALID' : 'invalid'));
$this->logger->debug('checking backup code: '.($backupItem->isHit() ? 'HIT & ' : 'miss & ').($backupItem->get() ? 'VALID' : 'invalid'));
if ($backupItem->isHit() && $backupItem->get()) {
$this->logger->debug("valid backup code");
$this->logger->debug('valid backup code');
/* mark backup code as spent */
$backupItem->set(false); /* used */
/* per PSR6, if no expiration is set, implementation may set a default,
* we want this to keep forever, so a few hundred years should do it */
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
'Y-m-d',
AppConstants::FAR_FUTURE_DATE
AppConstants::FAR_FUTURE_DATE,
));
$this->sessionCache->save($backupItem);
return true;
}
return false;
}
@@ -105,7 +111,7 @@ final readonly class BackupCodeManager implements BackupCodeInterface
* we want this to keep forever, so a few hundred years should do it */
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
'Y-m-d',
AppConstants::FAR_FUTURE_DATE
AppConstants::FAR_FUTURE_DATE,
));
$this->sessionCache->saveDeferred($backupItem);
}
+5 -5
View File
@@ -6,21 +6,21 @@ namespace App\Service;
interface DomainInterface
{
/** IE: "auth.example.com" or null if not using a separate subdomain
/** IE: "auth.example.com" or null if not using a separate subdomain.
* @return ?string Returns auth subdomain if configured, otherwise null */
public function getAuthSubdomain(): ?string;
/** check if given url is an acceptable url for redirection
/** check if given url is an acceptable url for redirection.
* @param string $url Where we are thinking of sending the user
*
* @return bool Returns true if it is acceptable to send the user there */
public function validReturn(string $url): bool;
/** check if host-base matches auth-base
* @param string $host
/** check if host-base matches auth-base.
* @return bool returns true if and only if host matches base domain of auth */
public function matchesAuth(string $host): bool;
/** IE: "example.com" if central auth is something like "auth.example.com"
/** IE: "example.com" if central auth is something like "auth.example.com".
* @return string|null returns base domain if we are doing central auth */
public function authBase(): ?string;
}
+121 -112
View File
@@ -10,100 +10,100 @@ final readonly class DomainManager implements DomainInterface
{
/* top-level-domains which are known to have multiple parts */
private const array TLD = [
'ai' => ['com','net','off','org'],
'am' => ['radio'],
'at' => ['ac','co','gv','or'],
'au' => ['com','net','org','edu','gov','asn','id'],
'az' => ['com','net','org'],
'bd' => ['com','net','org','gov','mil','ac'],
'br' => ['com','net','org','gov','mil','eco','emp','g12','ind','inf','rec','tur','tv','edu','far','gov','gru','jor','leg','lec','med','nom','not','ppg','pro','psi','pub','slg','srv','tec','tmp','vip','vlog','wiki','zlg'],
'by' => ['com','net','org','gov','mil','of'],
'ca' => ['ab','bc','mb','nb','nf','nl','ns','nt','nu','on','pe','qc','sk','yk'],
'cc' => [],
'cn' => ['com','net','org','gov','edu','ac','bj','sh','tj','cq','he','sx','nm','ln','jl','hl','js','zj','ah','fj','jx','sd','ha','hb','hn','gd','gx','hi','sc','gz','yn','sn','gs','qh','nx','xj','tw','hk','mo'],
'co' => ['com','net','org','gov','mil','edu','arts','firm','info','int','nom','rec','web'],
'com' => ['br','cn','co','de','eu','gr','it','jpn','mex','ru','sa','uk','us','za','au','bh','bo','cn','ec','eg','gt','hk','hn','il','in','jp','kr','kw','lb','lv','my','mx','ng','ni','np','pe','pf','pg','ph','pk','pl','pr','py','sa','sg','sv','tr','tw','ua','uy','ve','vn','ye'],
'de' => ['com'],
'dk' => ['co'],
'ec' => ['com','net','org','gov','mil','edu','fin','med','pro'],
'ee' => ['com','org','pri'],
'eg' => ['com','net','org','gov','edu','mil'],
'es' => ['com','nom','org','edu','gob'],
'eu' => [],
'fi' => ['aland'],
'fm' => ['radio'],
'fr' => ['com','nom','tm','asso','gouv','pol'],
'ge' => ['com','net','org','edu','gov','mil'],
'gg' => ['co','net','org'],
'gr' => ['com','net','org','gov','edu','mil'],
'hk' => ['com','net','org','gov','edu','idv'],
'hu' => ['co','2000','privat','sport','tm','erotica','sex','video','info','org','net','gov','edu','mil','press','biz'],
'id' => ['ac','biz','co','desa','go','mil','my','net','or','sch','web'],
'ie' => ['gov'],
'il' => ['ac','co','gov','idf','k12','muni','net','org'],
'in' => ['co','firm','gen','ind','net','org','ac','edu','res','gov','mil'],
'iq' => ['com','net','org','gov','edu','mil'],
'ir' => ['ac','co','gov','id','net','org','sch'],
'is' => ['net','com','org','edu','gov','int'],
'it' => ['ab','ag','al','an','ao','ap','aq','ar','at','av','ba','bg','bi','bl','bn','bo','br','bs','bt','bz','ca','cb','ce','ch','cl','cn','co','cr','cs','ct','cz','en','fc','fe','fg','fi','fm','fr','ge','go','gr','im','is','kr','lc','le','li','lo','lt','lu','mb','mc','me','mi','mn','mo','ms','mt','na','no','nu','or','pa','pc','pd','pe','pg','pi','pn','po','pr','pt','pu','pv','pz','re','rg','ri','rm','rn','ro','sa','si','so','sp','sr','ss','su','sv','ta','te','tn','to','tp','tr','ts','tv','ud','va','vb','vc','ve','vi','vr','vt','vv','edu','gov','abruzzo','basilicata','calabria','campania','emilia-romagna','friuli-ve-giulia','lazio','liguria','lombardia','marche','molise','piemonte','puglia','sardegna','sicilia','toscana','trentino-a-adige','umbria','valle-aosta','veneto'],
'je' => ['co','net','org'],
'jo' => ['com','net','org','gov','edu','mil','sch'],
'jp' => ['ac','ad','co','ed','go','gr','lg','ne','or'],
'ke' => ['co','ne','or','ac','go','me','mobi','info','sc','pro'],
'kg' => ['com','net','org','gov','mil','edu'],
'kr' => ['ac','co','go','hs','kg','mil','ms','ne','or','pe','re','seoul','busan','daegu','incheon','gwangju','daejeon','ulsan','gyeonggi','gangwon','chungbuk','chungnam','jeonbuk','jeonnam','gyeongbuk','gyeongnam','jeju','sejong'],
'kz' => ['com','net','org','edu','gov','mil'],
'li' => [],
'lt' => ['gov'],
'lv' => ['com','net','org','edu','gov','mil','id','asn','conf'],
'ly' => ['com','net','org','gov','edu','sch','med','id'],
'ma' => ['co','net','org','gov','press','ac'],
'mk' => ['com','net','org','edu','gov','inf','name','pro'],
'mx' => ['com','net','org','gov','edu','mil'],
'my' => ['com','net','org','gov','edu','mil','name'],
'na' => ['com','net','org','alt','edu','gov','mil','pro'],
'net' => ['gb','hu','in','jp','se','uk','cn','nz'],
'ng' => ['com','net','org','gov','edu','mil','sch','name','gov'],
'ni' => ['ac','co','com','edu','gob','mil','net','nom','org'],
'nl' => ['bv','co'],
'no' => ['fhs','folkebibl','kommune','mil','stat','priv','vgs','dep','kommune'],
'nz' => ['co','net','org','ac','geek','gen','maori','school','parliament','govt','health','mil','crii','archie','geek','govt','health','maori','school'],
'om' => ['com','net','org','gov','edu','med','mil','sch'],
'org' => ['ae','us','lu'],
'pe' => ['com','net','org','gob','edu','mil','nom'],
'ph' => ['com','net','org','gov','edu','mil'],
'pk' => ['com','net','org','fam','biz','edu','gov','web'],
'pl' => ['com','net','org','aid','agro','atm','auto','biz','edu','gmina','gsm','info','mail','miasta','media','mil','ngo','nom','pc','powiat','priv','realestate','rel','sex','shop','sklep','sos','szkola','targi','tm','tourism','travel','turystyka','gov','ap','augov','bedzin','bialystok','bielawa','bierun','boleslawiec','bydgoszcz','bytom','cieszyn','czeladz','czest','dlugoleka','elblag','elk','glogow','gniezno','gorlice','gorzow','grodzisk','grudziadz','ilk','jaworzno','jelenia-gora','jgora','kalisz','kazimierz-dolny','karpacz','kartuzy','kaszuby','katowice','kepno','ketrzyn','klodzko','kobierzyce','kolobrzeg','konin','konskowola','krapkowice','krakow','krasnik','krasno','krosniewice','kutno','lapy','lebork','legnica','lezajsk','limanowa','lomza','lowicz','lubin','lukow','malbork','malopolska','mazowsze','mazury','mielec','milicz','mielno','mragowo','naklo','nowaruda','nysa','olawa','olecko','olkusz','olsztyn','opoczno','opole','ostrowiec','ostroleka','ostrowwlkp','pila','pisz','podhale','podlasie','polkowice','pomorze','pomorse','prochowice','pruszkow','przeworsk','pulawy','rabka','rawa-maz','rybnik','rzeszow','sanok','sejny','siedlce','slask','slupsk','sosnowiec','stalowa-wola','skoczow','starachowice','stargard','suwalki','swidnica','swiebodzin','swinoujscie','szczecin','szczytno','tarnobrzeg','tgory','turek','tychy','ustka','walbrzych','warmia','warszawa','waw','wegrow','wielun','wlocl','wloclawek','wodzislaw','wolomin','wroclaw','zachpomor','zagan','zarow','zgora','zgorzelec','plug'],
'pr' => ['ac','co','edu','gov','info','island','pro','net','org'],
'pt' => ['com','net','org','gov','edu','int','publ'],
'py' => ['com','net','org','gov','edu','mil','co'],
'qa' => ['com','net','org','gov','edu','mil','sch','name'],
'ro' => ['com','net','org','nom','rec','info','arts','com','firm','tm','www','store','nt','ngo','pro','tm','com','arts','rec','store','info','nom','nt','org','shop','firm','www','rest','travel','transport','tourism','press','media','medical','med','law','jobs','inst','individual','insinfo','guru','fit','engineering','expert','energy','economy','dot','dog','dev','design','dem','dental','craft','corp','consulting','construction','company','com','club','cloud','coach','city','cinema','church','chat','casino','cars','care','cards','broke','blog','bio','bid','band','auto','audio','attorney','apartments','app','art','archi','architects','arena','architects','associates','attorney','auction','auto','baby','band','bank','bar','bargains','beer','berlin','best','bet','bid','bike','bingo','bio','black','blog','blue','boats','bond','boo','book','boutique','build','builders','business','buzz','cab','cafe','call','cam','camp','capital','care','careers','cars','cash','casino','catering','center','ceo','ceramics','cfd','ch','chat','church','city','claims','cleaning','click','clinic','clothing','cloud','club','coach','codes','coffee','college','community','company','computer','condos','construction','consulting','contact','cooking','cool','country','courses','cpa','craft','credit','creditcard','cricket','cruise','cuisinella','cymru','dabur','dance','date','dating','deals','degree','delivery','democrat','dental','design','dev','diamonds','diet','digital','direct','directory','discount','dog','domains','doos','download','ec','edu','education','energy','engineering','enterprises','equipment','estate','events','exchange','expert','exposed','express','fail','faith','family','fan','farm','fashion','film','finance','financial','fish','fit','fitness','flights','florist','flowers','football','forex','forsale','foundation','fun','fund','furniture','futbol','fyi','gal','gallery','game','garden','gift','gifts','gives','glass','global','gold','golf','graphics','gratis','green','gripe','group','guru','health','healthcare','help','helsinki','here','hiphop','hiv','holdings','holiday','homes','horse','host','hosting','house','how','immo','immobilien','in','industries','info','ink','institute','insure','international','investments','irish','jewelry','kaufen','kids','kim','kitchen','kiwi','kred','land','law','lawyer','legal','lgbt','lifestyle','lighting','limited','limo','link','live','loan','loans','lol','london','love','ltd','ltda','luxury','maison','management','market','marketing','markets','media','memorial','men','menu','miami','mobi','moda','moe','mom','money','monster','mortgage','movie','nagoya','name','navy','net','network','news','ngo','ninja','nyc','observer','okinawa','one','ong','onl','online','ooo','org','organic','osaka','paris','partners','parts','party','photo','photography','photos','pics','pictures','pink','pizza','place','plumbing','plus','poker','porn','press','pro','productions','properties','property','pub','qpon','realtor','realty','recipes','red','rehab','reise','reisen','rent','rentals','repair','report','rest','restaurant','review','reviews','rich','rip','rocks','rodeo','run','saarland','sale','salon','sarl','save','saxo','school','schule','science','services','sex','sexy','sg','shop','shopping','show','singles','site','ski','soccer','social','software','solar','solutions','space','store','stream','studio','study','style','supplies','supply','support','surgery','systems','tax','taxi','team','tech','technology','tennis','thai','tips','tires','tirol','today','tokyo','tools','top','tour','tours','town','toys','trade','trading','training','travel','tube','university','uno','vacations','vegas','ventures','vet','viajes','video','villas','vin','vision','vlaanderen','vodka','vote','voting','voto','voyage','wales','watch','webcam','website','wedding','wien','wiki','win','wine','work','works','world','wtf','xxx','xyz','yoga','yokohama','zone'],
'ru' => ['ac','com','edu','int','net','org','pp','adygeya','altai','amur','arkhangelsk','astrakhan','bashkiria','belgorod','bir','bryansk','buryatia','cbg','chel','chelyabinsk','chita','chukotka','chuvashia','dagestan','dudinka','e-burg','grozny','irkutsk','ivanovo','izhevsk','jar','joshkar-ola','kalmykia','kaluga','kamchatka','karelia','kazan','kchr','kemerovo','khabarovsk','khakassia','khv','kirov','koenigsberg','komi','kostroma','krasnodar','krasnoyarsk','kuban','kurgan','kursk','lipetsk','magadan','mari','mari-el','marine','mil','mordovia','mosreg','msk','murmansk','nalchik','nnov','nov','novosibirsk','nsk','omsk','orenburg','oryol','palana','penza','perm','ptz','rnd','ryazan','sakhalin','samara','saratov','simbirsk','smolensk','spb','stavropol','stv','surgut','tambov','tatarstan','tom','tomsk','tsaritsyn','tsk','tula','tuva','tver','tyumen','udm','udmurtia','ulan-ude','vladikavkaz','vladimir','vladivostok','volgograd','vologda','voronezh','vrn','vyatka','yakutia','yamal','yaroslavl','yevrey'],
'sa' => ['com','net','org','gov','med','pub','edu','sch'],
'sb' => ['com','net','org','edu','gov'],
'sc' => ['com','net','org','gov','edu'],
'se' => ['a','ac','b','bd','brand','c','d','e','f','fh','fhsk','fhv','g','h','i','k','komforb','kommunal','komvux','kunskapsforb','l','lanbib','m','n','naturbruksgymn','o','org','p','parti','pp','press','r','s','t','tm','u','v','w','x','y','z'],
'sg' => ['com','net','org','gov','edu','per'],
'sh' => ['com','net','org','gov','mil','edu'],
'sk' => ['co','com','edu','gov','mil','net','org','nfo'],
'st' => ['co','com','consulado','edu','embaixada','gov','mil','net','org','principe','saotome','store'],
'su' => ['abkhazia','adygeya','ak', 'altai','amur','arkhangelsk','astrakhan','bashkiria','belgorod','bir','bryansk','buryatia','cbg','chel','chelyabinsk','chita','chukotka','chuvashia','dagestan','dudinka','e-burg','grozny','irkutsk','ivanovo','izhevsk','jar','joshkar-ola','kalmykia','kaluga','kamchatka','karelia','kazan','kchr','kemerovo','khabarovsk','khakassia','khv','kirov','koenigsberg','komi','kostroma','krasnodar','krasnoyarsk','kuban','kurgan','kursk','lipetsk','magadan','mari','mari-el','marine','mil','mordovia','mosreg','msk','murmansk','nalchik','nnov','nov','novosibirsk','nsk','omsk','orenburg','oryol','palana','penza','perm','ptz','rnd','ryazan','sakhalin','samara','saratov','simbirsk','smolensk','spb','stavropol','stv','surgut','tambov','tatarstan','tom','tomsk','tsaritsyn','tsk','tula','tuva','tver','tyumen','udm','udmurtia','ulan-ude','vladikavkaz','vladimir','vladivostok','volgograd','vologda','voronezh','vrn','vyatka','yakutia','yamal','yaroslavl','yevrey','com','net','org','gov','pp','edu'],
'sv' => ['com','edu','gob','org','red'],
'sy' => ['com','net','org','gov','edu','mil','name'],
'th' => ['ac','co','go','in','mi','net','or'],
'tj' => ['ac','biz','co','com','edu','gov','go','info','int','mil','name','net','nic','nom','org','pro','test','web'],
'tn' => ['agrinet','com','defense','edunet','ens','fin','gov','ind','info','intl','min','nat','net','org','perso','rnrt','rns','rnu','tourism','turen'],
'tr' => ['com','net','org','gov','biz','info','mil','edu','tv','bbs','k12','pol','bel','dr','gen','av','bbs','k12','name','tel','nc','web','tsk','bel','pol','edu'],
'tw' => ['com','net','org','edu','gov','mil','idv','game','ebiz','club','gnu'],
'ua' => ['com','net','org','edu','gov','in','at','cn','crimea','dn','dnepropetrovsk','donetsk','dp','if','ivano-frankivsk','kh','kharkov','kherson','khmelnitskiy','kiev','kirovograd','km','kr','ks','kv','lg','lt','lugansk','lutsk','lv','lviv','mk','mk.ua','mykolaiv','net','nikolaev','od','odessa','pl','poltava','rovno','rv','sebastopol','sm','sumy','te','ternopil','uz','uzhgorod','vinnica','vn','volyn','yalta','zaporizhzhe','zhitomir','zp','zt'],
'uk' => ['co','me','org','ltd','plc','net','sch','ac','gov','nhs','police','mod','nhs','parliament'],
'us' => ['ak','al','ar','as','az','ca','co','ct','dc','de','fl','ga','gu','hi','ia','id','il','in','ks','ky','la','ma','md','me','mi','mn','mo','ms','mt','nc','nd','ne','nh','nj','nm','nv','ny','oh','ok','or','pa','pr','ri','sc','sd','tn','tx','ut','vi','vt','va','wa','wi','wv','wy','dni','fed','isa','kids','nsn'],
'uy' => ['com','net','org','gub','mil','edu'],
've' => ['co','com','edu','gob','info','net','org','web'],
'vn' => ['com','net','org','edu','gov','int','ac','biz','info','name','pro','health'],
'yu' => ['ac','co','edu','gov','org'],
'za' => ['ac','alt','co','edu','gov','law','mil','net','ngo','nom','org','school','tm','web'],
'ai' => ['com', 'net', 'off', 'org'],
'am' => ['radio'],
'at' => ['ac', 'co', 'gv', 'or'],
'au' => ['com', 'net', 'org', 'edu', 'gov', 'asn', 'id'],
'az' => ['com', 'net', 'org'],
'bd' => ['com', 'net', 'org', 'gov', 'mil', 'ac'],
'br' => ['com', 'net', 'org', 'gov', 'mil', 'eco', 'emp', 'g12', 'ind', 'inf', 'rec', 'tur', 'tv', 'edu', 'far', 'gov', 'gru', 'jor', 'leg', 'lec', 'med', 'nom', 'not', 'ppg', 'pro', 'psi', 'pub', 'slg', 'srv', 'tec', 'tmp', 'vip', 'vlog', 'wiki', 'zlg'],
'by' => ['com', 'net', 'org', 'gov', 'mil', 'of'],
'ca' => ['ab', 'bc', 'mb', 'nb', 'nf', 'nl', 'ns', 'nt', 'nu', 'on', 'pe', 'qc', 'sk', 'yk'],
'cc' => [],
'cn' => ['com', 'net', 'org', 'gov', 'edu', 'ac', 'bj', 'sh', 'tj', 'cq', 'he', 'sx', 'nm', 'ln', 'jl', 'hl', 'js', 'zj', 'ah', 'fj', 'jx', 'sd', 'ha', 'hb', 'hn', 'gd', 'gx', 'hi', 'sc', 'gz', 'yn', 'sn', 'gs', 'qh', 'nx', 'xj', 'tw', 'hk', 'mo'],
'co' => ['com', 'net', 'org', 'gov', 'mil', 'edu', 'arts', 'firm', 'info', 'int', 'nom', 'rec', 'web'],
'com' => ['br', 'cn', 'co', 'de', 'eu', 'gr', 'it', 'jpn', 'mex', 'ru', 'sa', 'uk', 'us', 'za', 'au', 'bh', 'bo', 'cn', 'ec', 'eg', 'gt', 'hk', 'hn', 'il', 'in', 'jp', 'kr', 'kw', 'lb', 'lv', 'my', 'mx', 'ng', 'ni', 'np', 'pe', 'pf', 'pg', 'ph', 'pk', 'pl', 'pr', 'py', 'sa', 'sg', 'sv', 'tr', 'tw', 'ua', 'uy', 've', 'vn', 'ye'],
'de' => ['com'],
'dk' => ['co'],
'ec' => ['com', 'net', 'org', 'gov', 'mil', 'edu', 'fin', 'med', 'pro'],
'ee' => ['com', 'org', 'pri'],
'eg' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
'es' => ['com', 'nom', 'org', 'edu', 'gob'],
'eu' => [],
'fi' => ['aland'],
'fm' => ['radio'],
'fr' => ['com', 'nom', 'tm', 'asso', 'gouv', 'pol'],
'ge' => ['com', 'net', 'org', 'edu', 'gov', 'mil'],
'gg' => ['co', 'net', 'org'],
'gr' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
'hk' => ['com', 'net', 'org', 'gov', 'edu', 'idv'],
'hu' => ['co', '2000', 'privat', 'sport', 'tm', 'erotica', 'sex', 'video', 'info', 'org', 'net', 'gov', 'edu', 'mil', 'press', 'biz'],
'id' => ['ac', 'biz', 'co', 'desa', 'go', 'mil', 'my', 'net', 'or', 'sch', 'web'],
'ie' => ['gov'],
'il' => ['ac', 'co', 'gov', 'idf', 'k12', 'muni', 'net', 'org'],
'in' => ['co', 'firm', 'gen', 'ind', 'net', 'org', 'ac', 'edu', 'res', 'gov', 'mil'],
'iq' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
'ir' => ['ac', 'co', 'gov', 'id', 'net', 'org', 'sch'],
'is' => ['net', 'com', 'org', 'edu', 'gov', 'int'],
'it' => ['ab', 'ag', 'al', 'an', 'ao', 'ap', 'aq', 'ar', 'at', 'av', 'ba', 'bg', 'bi', 'bl', 'bn', 'bo', 'br', 'bs', 'bt', 'bz', 'ca', 'cb', 'ce', 'ch', 'cl', 'cn', 'co', 'cr', 'cs', 'ct', 'cz', 'en', 'fc', 'fe', 'fg', 'fi', 'fm', 'fr', 'ge', 'go', 'gr', 'im', 'is', 'kr', 'lc', 'le', 'li', 'lo', 'lt', 'lu', 'mb', 'mc', 'me', 'mi', 'mn', 'mo', 'ms', 'mt', 'na', 'no', 'nu', 'or', 'pa', 'pc', 'pd', 'pe', 'pg', 'pi', 'pn', 'po', 'pr', 'pt', 'pu', 'pv', 'pz', 're', 'rg', 'ri', 'rm', 'rn', 'ro', 'sa', 'si', 'so', 'sp', 'sr', 'ss', 'su', 'sv', 'ta', 'te', 'tn', 'to', 'tp', 'tr', 'ts', 'tv', 'ud', 'va', 'vb', 'vc', 've', 'vi', 'vr', 'vt', 'vv', 'edu', 'gov', 'abruzzo', 'basilicata', 'calabria', 'campania', 'emilia-romagna', 'friuli-ve-giulia', 'lazio', 'liguria', 'lombardia', 'marche', 'molise', 'piemonte', 'puglia', 'sardegna', 'sicilia', 'toscana', 'trentino-a-adige', 'umbria', 'valle-aosta', 'veneto'],
'je' => ['co', 'net', 'org'],
'jo' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'sch'],
'jp' => ['ac', 'ad', 'co', 'ed', 'go', 'gr', 'lg', 'ne', 'or'],
'ke' => ['co', 'ne', 'or', 'ac', 'go', 'me', 'mobi', 'info', 'sc', 'pro'],
'kg' => ['com', 'net', 'org', 'gov', 'mil', 'edu'],
'kr' => ['ac', 'co', 'go', 'hs', 'kg', 'mil', 'ms', 'ne', 'or', 'pe', 're', 'seoul', 'busan', 'daegu', 'incheon', 'gwangju', 'daejeon', 'ulsan', 'gyeonggi', 'gangwon', 'chungbuk', 'chungnam', 'jeonbuk', 'jeonnam', 'gyeongbuk', 'gyeongnam', 'jeju', 'sejong'],
'kz' => ['com', 'net', 'org', 'edu', 'gov', 'mil'],
'li' => [],
'lt' => ['gov'],
'lv' => ['com', 'net', 'org', 'edu', 'gov', 'mil', 'id', 'asn', 'conf'],
'ly' => ['com', 'net', 'org', 'gov', 'edu', 'sch', 'med', 'id'],
'ma' => ['co', 'net', 'org', 'gov', 'press', 'ac'],
'mk' => ['com', 'net', 'org', 'edu', 'gov', 'inf', 'name', 'pro'],
'mx' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
'my' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'name'],
'na' => ['com', 'net', 'org', 'alt', 'edu', 'gov', 'mil', 'pro'],
'net' => ['gb', 'hu', 'in', 'jp', 'se', 'uk', 'cn', 'nz'],
'ng' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'sch', 'name', 'gov'],
'ni' => ['ac', 'co', 'com', 'edu', 'gob', 'mil', 'net', 'nom', 'org'],
'nl' => ['bv', 'co'],
'no' => ['fhs', 'folkebibl', 'kommune', 'mil', 'stat', 'priv', 'vgs', 'dep', 'kommune'],
'nz' => ['co', 'net', 'org', 'ac', 'geek', 'gen', 'maori', 'school', 'parliament', 'govt', 'health', 'mil', 'crii', 'archie', 'geek', 'govt', 'health', 'maori', 'school'],
'om' => ['com', 'net', 'org', 'gov', 'edu', 'med', 'mil', 'sch'],
'org' => ['ae', 'us', 'lu'],
'pe' => ['com', 'net', 'org', 'gob', 'edu', 'mil', 'nom'],
'ph' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
'pk' => ['com', 'net', 'org', 'fam', 'biz', 'edu', 'gov', 'web'],
'pl' => ['com', 'net', 'org', 'aid', 'agro', 'atm', 'auto', 'biz', 'edu', 'gmina', 'gsm', 'info', 'mail', 'miasta', 'media', 'mil', 'ngo', 'nom', 'pc', 'powiat', 'priv', 'realestate', 'rel', 'sex', 'shop', 'sklep', 'sos', 'szkola', 'targi', 'tm', 'tourism', 'travel', 'turystyka', 'gov', 'ap', 'augov', 'bedzin', 'bialystok', 'bielawa', 'bierun', 'boleslawiec', 'bydgoszcz', 'bytom', 'cieszyn', 'czeladz', 'czest', 'dlugoleka', 'elblag', 'elk', 'glogow', 'gniezno', 'gorlice', 'gorzow', 'grodzisk', 'grudziadz', 'ilk', 'jaworzno', 'jelenia-gora', 'jgora', 'kalisz', 'kazimierz-dolny', 'karpacz', 'kartuzy', 'kaszuby', 'katowice', 'kepno', 'ketrzyn', 'klodzko', 'kobierzyce', 'kolobrzeg', 'konin', 'konskowola', 'krapkowice', 'krakow', 'krasnik', 'krasno', 'krosniewice', 'kutno', 'lapy', 'lebork', 'legnica', 'lezajsk', 'limanowa', 'lomza', 'lowicz', 'lubin', 'lukow', 'malbork', 'malopolska', 'mazowsze', 'mazury', 'mielec', 'milicz', 'mielno', 'mragowo', 'naklo', 'nowaruda', 'nysa', 'olawa', 'olecko', 'olkusz', 'olsztyn', 'opoczno', 'opole', 'ostrowiec', 'ostroleka', 'ostrowwlkp', 'pila', 'pisz', 'podhale', 'podlasie', 'polkowice', 'pomorze', 'pomorse', 'prochowice', 'pruszkow', 'przeworsk', 'pulawy', 'rabka', 'rawa-maz', 'rybnik', 'rzeszow', 'sanok', 'sejny', 'siedlce', 'slask', 'slupsk', 'sosnowiec', 'stalowa-wola', 'skoczow', 'starachowice', 'stargard', 'suwalki', 'swidnica', 'swiebodzin', 'swinoujscie', 'szczecin', 'szczytno', 'tarnobrzeg', 'tgory', 'turek', 'tychy', 'ustka', 'walbrzych', 'warmia', 'warszawa', 'waw', 'wegrow', 'wielun', 'wlocl', 'wloclawek', 'wodzislaw', 'wolomin', 'wroclaw', 'zachpomor', 'zagan', 'zarow', 'zgora', 'zgorzelec', 'plug'],
'pr' => ['ac', 'co', 'edu', 'gov', 'info', 'island', 'pro', 'net', 'org'],
'pt' => ['com', 'net', 'org', 'gov', 'edu', 'int', 'publ'],
'py' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'co'],
'qa' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'sch', 'name'],
'ro' => ['com', 'net', 'org', 'nom', 'rec', 'info', 'arts', 'com', 'firm', 'tm', 'www', 'store', 'nt', 'ngo', 'pro', 'tm', 'com', 'arts', 'rec', 'store', 'info', 'nom', 'nt', 'org', 'shop', 'firm', 'www', 'rest', 'travel', 'transport', 'tourism', 'press', 'media', 'medical', 'med', 'law', 'jobs', 'inst', 'individual', 'insinfo', 'guru', 'fit', 'engineering', 'expert', 'energy', 'economy', 'dot', 'dog', 'dev', 'design', 'dem', 'dental', 'craft', 'corp', 'consulting', 'construction', 'company', 'com', 'club', 'cloud', 'coach', 'city', 'cinema', 'church', 'chat', 'casino', 'cars', 'care', 'cards', 'broke', 'blog', 'bio', 'bid', 'band', 'auto', 'audio', 'attorney', 'apartments', 'app', 'art', 'archi', 'architects', 'arena', 'architects', 'associates', 'attorney', 'auction', 'auto', 'baby', 'band', 'bank', 'bar', 'bargains', 'beer', 'berlin', 'best', 'bet', 'bid', 'bike', 'bingo', 'bio', 'black', 'blog', 'blue', 'boats', 'bond', 'boo', 'book', 'boutique', 'build', 'builders', 'business', 'buzz', 'cab', 'cafe', 'call', 'cam', 'camp', 'capital', 'care', 'careers', 'cars', 'cash', 'casino', 'catering', 'center', 'ceo', 'ceramics', 'cfd', 'ch', 'chat', 'church', 'city', 'claims', 'cleaning', 'click', 'clinic', 'clothing', 'cloud', 'club', 'coach', 'codes', 'coffee', 'college', 'community', 'company', 'computer', 'condos', 'construction', 'consulting', 'contact', 'cooking', 'cool', 'country', 'courses', 'cpa', 'craft', 'credit', 'creditcard', 'cricket', 'cruise', 'cuisinella', 'cymru', 'dabur', 'dance', 'date', 'dating', 'deals', 'degree', 'delivery', 'democrat', 'dental', 'design', 'dev', 'diamonds', 'diet', 'digital', 'direct', 'directory', 'discount', 'dog', 'domains', 'doos', 'download', 'ec', 'edu', 'education', 'energy', 'engineering', 'enterprises', 'equipment', 'estate', 'events', 'exchange', 'expert', 'exposed', 'express', 'fail', 'faith', 'family', 'fan', 'farm', 'fashion', 'film', 'finance', 'financial', 'fish', 'fit', 'fitness', 'flights', 'florist', 'flowers', 'football', 'forex', 'forsale', 'foundation', 'fun', 'fund', 'furniture', 'futbol', 'fyi', 'gal', 'gallery', 'game', 'garden', 'gift', 'gifts', 'gives', 'glass', 'global', 'gold', 'golf', 'graphics', 'gratis', 'green', 'gripe', 'group', 'guru', 'health', 'healthcare', 'help', 'helsinki', 'here', 'hiphop', 'hiv', 'holdings', 'holiday', 'homes', 'horse', 'host', 'hosting', 'house', 'how', 'immo', 'immobilien', 'in', 'industries', 'info', 'ink', 'institute', 'insure', 'international', 'investments', 'irish', 'jewelry', 'kaufen', 'kids', 'kim', 'kitchen', 'kiwi', 'kred', 'land', 'law', 'lawyer', 'legal', 'lgbt', 'lifestyle', 'lighting', 'limited', 'limo', 'link', 'live', 'loan', 'loans', 'lol', 'london', 'love', 'ltd', 'ltda', 'luxury', 'maison', 'management', 'market', 'marketing', 'markets', 'media', 'memorial', 'men', 'menu', 'miami', 'mobi', 'moda', 'moe', 'mom', 'money', 'monster', 'mortgage', 'movie', 'nagoya', 'name', 'navy', 'net', 'network', 'news', 'ngo', 'ninja', 'nyc', 'observer', 'okinawa', 'one', 'ong', 'onl', 'online', 'ooo', 'org', 'organic', 'osaka', 'paris', 'partners', 'parts', 'party', 'photo', 'photography', 'photos', 'pics', 'pictures', 'pink', 'pizza', 'place', 'plumbing', 'plus', 'poker', 'porn', 'press', 'pro', 'productions', 'properties', 'property', 'pub', 'qpon', 'realtor', 'realty', 'recipes', 'red', 'rehab', 'reise', 'reisen', 'rent', 'rentals', 'repair', 'report', 'rest', 'restaurant', 'review', 'reviews', 'rich', 'rip', 'rocks', 'rodeo', 'run', 'saarland', 'sale', 'salon', 'sarl', 'save', 'saxo', 'school', 'schule', 'science', 'services', 'sex', 'sexy', 'sg', 'shop', 'shopping', 'show', 'singles', 'site', 'ski', 'soccer', 'social', 'software', 'solar', 'solutions', 'space', 'store', 'stream', 'studio', 'study', 'style', 'supplies', 'supply', 'support', 'surgery', 'systems', 'tax', 'taxi', 'team', 'tech', 'technology', 'tennis', 'thai', 'tips', 'tires', 'tirol', 'today', 'tokyo', 'tools', 'top', 'tour', 'tours', 'town', 'toys', 'trade', 'trading', 'training', 'travel', 'tube', 'university', 'uno', 'vacations', 'vegas', 'ventures', 'vet', 'viajes', 'video', 'villas', 'vin', 'vision', 'vlaanderen', 'vodka', 'vote', 'voting', 'voto', 'voyage', 'wales', 'watch', 'webcam', 'website', 'wedding', 'wien', 'wiki', 'win', 'wine', 'work', 'works', 'world', 'wtf', 'xxx', 'xyz', 'yoga', 'yokohama', 'zone'],
'ru' => ['ac', 'com', 'edu', 'int', 'net', 'org', 'pp', 'adygeya', 'altai', 'amur', 'arkhangelsk', 'astrakhan', 'bashkiria', 'belgorod', 'bir', 'bryansk', 'buryatia', 'cbg', 'chel', 'chelyabinsk', 'chita', 'chukotka', 'chuvashia', 'dagestan', 'dudinka', 'e-burg', 'grozny', 'irkutsk', 'ivanovo', 'izhevsk', 'jar', 'joshkar-ola', 'kalmykia', 'kaluga', 'kamchatka', 'karelia', 'kazan', 'kchr', 'kemerovo', 'khabarovsk', 'khakassia', 'khv', 'kirov', 'koenigsberg', 'komi', 'kostroma', 'krasnodar', 'krasnoyarsk', 'kuban', 'kurgan', 'kursk', 'lipetsk', 'magadan', 'mari', 'mari-el', 'marine', 'mil', 'mordovia', 'mosreg', 'msk', 'murmansk', 'nalchik', 'nnov', 'nov', 'novosibirsk', 'nsk', 'omsk', 'orenburg', 'oryol', 'palana', 'penza', 'perm', 'ptz', 'rnd', 'ryazan', 'sakhalin', 'samara', 'saratov', 'simbirsk', 'smolensk', 'spb', 'stavropol', 'stv', 'surgut', 'tambov', 'tatarstan', 'tom', 'tomsk', 'tsaritsyn', 'tsk', 'tula', 'tuva', 'tver', 'tyumen', 'udm', 'udmurtia', 'ulan-ude', 'vladikavkaz', 'vladimir', 'vladivostok', 'volgograd', 'vologda', 'voronezh', 'vrn', 'vyatka', 'yakutia', 'yamal', 'yaroslavl', 'yevrey'],
'sa' => ['com', 'net', 'org', 'gov', 'med', 'pub', 'edu', 'sch'],
'sb' => ['com', 'net', 'org', 'edu', 'gov'],
'sc' => ['com', 'net', 'org', 'gov', 'edu'],
'se' => ['a', 'ac', 'b', 'bd', 'brand', 'c', 'd', 'e', 'f', 'fh', 'fhsk', 'fhv', 'g', 'h', 'i', 'k', 'komforb', 'kommunal', 'komvux', 'kunskapsforb', 'l', 'lanbib', 'm', 'n', 'naturbruksgymn', 'o', 'org', 'p', 'parti', 'pp', 'press', 'r', 's', 't', 'tm', 'u', 'v', 'w', 'x', 'y', 'z'],
'sg' => ['com', 'net', 'org', 'gov', 'edu', 'per'],
'sh' => ['com', 'net', 'org', 'gov', 'mil', 'edu'],
'sk' => ['co', 'com', 'edu', 'gov', 'mil', 'net', 'org', 'nfo'],
'st' => ['co', 'com', 'consulado', 'edu', 'embaixada', 'gov', 'mil', 'net', 'org', 'principe', 'saotome', 'store'],
'su' => ['abkhazia', 'adygeya', 'ak', 'altai', 'amur', 'arkhangelsk', 'astrakhan', 'bashkiria', 'belgorod', 'bir', 'bryansk', 'buryatia', 'cbg', 'chel', 'chelyabinsk', 'chita', 'chukotka', 'chuvashia', 'dagestan', 'dudinka', 'e-burg', 'grozny', 'irkutsk', 'ivanovo', 'izhevsk', 'jar', 'joshkar-ola', 'kalmykia', 'kaluga', 'kamchatka', 'karelia', 'kazan', 'kchr', 'kemerovo', 'khabarovsk', 'khakassia', 'khv', 'kirov', 'koenigsberg', 'komi', 'kostroma', 'krasnodar', 'krasnoyarsk', 'kuban', 'kurgan', 'kursk', 'lipetsk', 'magadan', 'mari', 'mari-el', 'marine', 'mil', 'mordovia', 'mosreg', 'msk', 'murmansk', 'nalchik', 'nnov', 'nov', 'novosibirsk', 'nsk', 'omsk', 'orenburg', 'oryol', 'palana', 'penza', 'perm', 'ptz', 'rnd', 'ryazan', 'sakhalin', 'samara', 'saratov', 'simbirsk', 'smolensk', 'spb', 'stavropol', 'stv', 'surgut', 'tambov', 'tatarstan', 'tom', 'tomsk', 'tsaritsyn', 'tsk', 'tula', 'tuva', 'tver', 'tyumen', 'udm', 'udmurtia', 'ulan-ude', 'vladikavkaz', 'vladimir', 'vladivostok', 'volgograd', 'vologda', 'voronezh', 'vrn', 'vyatka', 'yakutia', 'yamal', 'yaroslavl', 'yevrey', 'com', 'net', 'org', 'gov', 'pp', 'edu'],
'sv' => ['com', 'edu', 'gob', 'org', 'red'],
'sy' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'name'],
'th' => ['ac', 'co', 'go', 'in', 'mi', 'net', 'or'],
'tj' => ['ac', 'biz', 'co', 'com', 'edu', 'gov', 'go', 'info', 'int', 'mil', 'name', 'net', 'nic', 'nom', 'org', 'pro', 'test', 'web'],
'tn' => ['agrinet', 'com', 'defense', 'edunet', 'ens', 'fin', 'gov', 'ind', 'info', 'intl', 'min', 'nat', 'net', 'org', 'perso', 'rnrt', 'rns', 'rnu', 'tourism', 'turen'],
'tr' => ['com', 'net', 'org', 'gov', 'biz', 'info', 'mil', 'edu', 'tv', 'bbs', 'k12', 'pol', 'bel', 'dr', 'gen', 'av', 'bbs', 'k12', 'name', 'tel', 'nc', 'web', 'tsk', 'bel', 'pol', 'edu'],
'tw' => ['com', 'net', 'org', 'edu', 'gov', 'mil', 'idv', 'game', 'ebiz', 'club', 'gnu'],
'ua' => ['com', 'net', 'org', 'edu', 'gov', 'in', 'at', 'cn', 'crimea', 'dn', 'dnepropetrovsk', 'donetsk', 'dp', 'if', 'ivano-frankivsk', 'kh', 'kharkov', 'kherson', 'khmelnitskiy', 'kiev', 'kirovograd', 'km', 'kr', 'ks', 'kv', 'lg', 'lt', 'lugansk', 'lutsk', 'lv', 'lviv', 'mk', 'mk.ua', 'mykolaiv', 'net', 'nikolaev', 'od', 'odessa', 'pl', 'poltava', 'rovno', 'rv', 'sebastopol', 'sm', 'sumy', 'te', 'ternopil', 'uz', 'uzhgorod', 'vinnica', 'vn', 'volyn', 'yalta', 'zaporizhzhe', 'zhitomir', 'zp', 'zt'],
'uk' => ['co', 'me', 'org', 'ltd', 'plc', 'net', 'sch', 'ac', 'gov', 'nhs', 'police', 'mod', 'nhs', 'parliament'],
'us' => ['ak', 'al', 'ar', 'as', 'az', 'ca', 'co', 'ct', 'dc', 'de', 'fl', 'ga', 'gu', 'hi', 'ia', 'id', 'il', 'in', 'ks', 'ky', 'la', 'ma', 'md', 'me', 'mi', 'mn', 'mo', 'ms', 'mt', 'nc', 'nd', 'ne', 'nh', 'nj', 'nm', 'nv', 'ny', 'oh', 'ok', 'or', 'pa', 'pr', 'ri', 'sc', 'sd', 'tn', 'tx', 'ut', 'vi', 'vt', 'va', 'wa', 'wi', 'wv', 'wy', 'dni', 'fed', 'isa', 'kids', 'nsn'],
'uy' => ['com', 'net', 'org', 'gub', 'mil', 'edu'],
've' => ['co', 'com', 'edu', 'gob', 'info', 'net', 'org', 'web'],
'vn' => ['com', 'net', 'org', 'edu', 'gov', 'int', 'ac', 'biz', 'info', 'name', 'pro', 'health'],
'yu' => ['ac', 'co', 'edu', 'gov', 'org'],
'za' => ['ac', 'alt', 'co', 'edu', 'gov', 'law', 'mil', 'net', 'ngo', 'nom', 'org', 'school', 'tm', 'web'],
];
private bool $subdomainRedirect;
@@ -111,38 +111,41 @@ final readonly class DomainManager implements DomainInterface
public function __construct(
#[Autowire('%app.subdomain_redirect%')] bool $subdomainRedirect,
#[Autowire('%app.auth_subdomain%')] string $authSubdomain,
#[Autowire('%app.auth_subdomain%')] string $authSubdomain,
) {
$this->subdomainRedirect = $subdomainRedirect;
$this->authSubdomain = $authSubdomain;
}
/** IE: "auth.example.com" or null if not using a separate subdomain
/** IE: "auth.example.com" or null if not using a separate subdomain.
* @return ?string Returns auth subdomain if configured, otherwise null */
public function getAuthSubdomain(): ?string
{
if ($this->authBase()) {
return $this->authSubdomain;
}
return null;
}
/** check if given url is an acceptable url for redirection
/** check if given url is an acceptable url for redirection.
* @param string $url Where we are thinking of sending the user
*
* @return bool Returns true if it is acceptable to send the user there */
public function validReturn(string $url): bool
{
/* ensure url is valid and, when using an auth subdomain,
* that the url host matches the base domain */
if (!filter_var($url, FILTER_VALIDATE_URL)) {
if (!filter_var($url, \FILTER_VALIDATE_URL)) {
return false;
}
if ($this->authBase()) {
$host = parse_url($url, PHP_URL_HOST);
if ($host === null || $host === false || $host === '') {
$host = parse_url($url, \PHP_URL_HOST);
if (null === $host || false === $host || '' === $host) {
return false;
}
/* do not send the user to another domain */
return $this->matchesAuth($host);
}
@@ -150,58 +153,64 @@ final readonly class DomainManager implements DomainInterface
return true;
}
/** check if host-base matches auth-base
* @param string $host
/** check if host-base matches auth-base.
* @return bool returns true if and only if host matches base domain of auth */
public function matchesAuth(string $host): bool
{
$hostBase = $this->baseDomain($host);
$authBase = $this->baseDomain($this->authSubdomain);
return $this->subdomainRedirect && $this->authSubdomain &&
$authBase && $authBase === $hostBase;
return $this->subdomainRedirect && $this->authSubdomain
&& $authBase && $authBase === $hostBase;
}
/** IE: "example.com" if central auth is something like "auth.example.com"
/** IE: "example.com" if central auth is something like "auth.example.com".
* @return string|null returns base domain if we are doing central auth */
public function authBase(): ?string
{
if ($this->subdomainRedirect && $this->authSubdomain && $this->baseDomain($this->authSubdomain)) {
return $this->baseDomain($this->authSubdomain);
}
return null;
}
/** this lets us determine the base domain of the given ip, localhost, or domain
* "service.example.co.uk" into "example.co.uk" and "service.example.com" into "example.com"
* things like "localhost" and "8.8.8.8" will return null
* things like "localhost" and "8.8.8.8" will return null.
*
* @param string $host ip, localhost, or domain with zero or more subdomains
*
* @return ?string returns null if host is ip or localhost otherwise domain with all subdomains removed */
private function baseDomain(string $host): ?string
{
/* if host is an ip address (or localhost), leave it as is */
if (filter_var($host, FILTER_VALIDATE_IP) || $host === 'localhost') {
if (filter_var($host, \FILTER_VALIDATE_IP) || 'localhost' === $host) {
return null;
}
$parts = explode('.', strtolower($host));
$keep = $this->baseLength($parts);
$parts = array_slice($parts, -$keep);
$parts = \array_slice($parts, -$keep);
return implode('.', $parts);
}
/** IE: ["www", "example", "com"] or ["www", "example", "co", "uk"]
/** IE: ["www", "example", "com"] or ["www", "example", "co", "uk"].
* @param string[] $parts pieces of a domain split by "." dot
*
* @return int typically 2 but sometimes 3 */
private function baseLength(array $parts): int
{
$length = count($parts);
$length = \count($parts);
$baseLength = min(2, $length);
/* check if host should retain 3 parts, due to TLD */
if (count($parts) > 2 && isset(self::TLD[$parts[$length - 1]]) &&
in_array($parts[$length - 2], self::TLD[$parts[$length - 1]], true)
if (\count($parts) > 2 && isset(self::TLD[$parts[$length - 1]])
&& \in_array($parts[$length - 2], self::TLD[$parts[$length - 1]], true)
) {
$baseLength = min(3, $length);
}
return $baseLength;
}
}
+18 -16
View File
@@ -31,8 +31,8 @@ final readonly class LoginManager implements LoginInterface
/** @throws InvalidArgumentException */
public function __construct(
CacheItemPoolInterface $sessionCache,
private BackupCodeInterface $backupCodeManager,
private DomainInterface $domainManager,
private BackupCodeInterface $backupCodeManager,
private DomainInterface $domainManager,
) {
$this->sessionCache = new MonitorCacheKeys($sessionCache);
}
@@ -41,13 +41,13 @@ final readonly class LoginManager implements LoginInterface
public function checkToken(Payload $payload, Request $request): ?Response
{
/* when scope is IP but ip-access is disabled, scope is to be considered cookie */
if ($payload->scope === Scope::Ip && ! $this->config->ipTtl()) {
if (Scope::Ip === $payload->scope && !$this->config->ipTtl()) {
/* requested to grant ip access, but that is not enabled */
$payload->scope = Scope::Cookie;
}
if ($this->getTotp()->verify($payload->token, null, 1) ||
$this->backupCodeManager->verifyAndConsume($payload->token)
if ($this->getTotp()->verify($payload->token, null, 1)
|| $this->backupCodeManager->verifyAndConsume($payload->token)
) {
/* token is correct (TOTP or Backup) */
@@ -56,7 +56,7 @@ final readonly class LoginManager implements LoginInterface
if ($nonceItem->isHit() && $nonceItem->get()) {
/* mark nonce as spent */
$nonceItem->set(false); /* invalid */
$nonceItem->expiresAfter(LoginManager::NONCE_TTL); /* keep briefly */
$nonceItem->expiresAfter(self::NONCE_TTL); /* keep briefly */
$this->nonceCache->save($nonceItem);
/* token authentication successful, grant access and set response */
@@ -65,27 +65,27 @@ final readonly class LoginManager implements LoginInterface
/* if they just want this one page, return ok, to grant them access */
$response = $this->authSuccessResponse($cleanId, $this->config);
if ($payload->scope !== Scope::None) {
if (Scope::None !== $payload->scope) {
/* grant access based on the requested scope */
if ($payload->scope === Scope::Cookie) {
if (Scope::Cookie === $payload->scope) {
$response->headers->setCookie($this->setCookie($cleanId, $request->getHost()));
} elseif ($payload->scope === Scope::Ip) {
} elseif (Scope::Ip === $payload->scope) {
$this->setIp($cleanId, $request->getClientIp());
}
if ($payload->json) {
$contentType = 'application/json';
$content = json_encode([
$content = json_encode([
'message' => 'Login successful',
'nonce' => null,
'nonce' => null,
]);
} else {
$contentType = 'text/html';
$content = "hi $cleanId, please reload";
$content = "hi $cleanId, please reload";
}
$location = $request->query->has('return') &&
$this->domainManager->validReturn($request->query->get('return')) ?
$location = $request->query->has('return')
&& $this->domainManager->validReturn($request->query->get('return')) ?
"{$request->query->get('return')}" :
"{$request->getPathInfo()}{$request->getQueryString()}";
@@ -97,9 +97,11 @@ final readonly class LoginManager implements LoginInterface
}
$this->logger->debug("successful login for: $cleanId");
return $response;
}
}
return null;
}
@@ -109,11 +111,11 @@ final readonly class LoginManager implements LoginInterface
/* successful auth with token, store session and set the cookie */
$ulid = new Ulid();
$sessionCookie = $this->sessionCache->getItem(
$this->makeCacheKey("cookie_$ulid")
$this->makeCacheKey("cookie_$ulid"),
);
if ($sessionCookie->isHit()) {
/* it is supposed to be impossible to have collisions */
$this->logger->error("aborting: ULID collision");
$this->logger->error('aborting: ULID collision');
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
}
$sessionCookie->set($id);
+15 -15
View File
@@ -32,23 +32,23 @@ final readonly class PublicPathMatcher implements PublicPathMatcherInterface
public function isEmpty(): bool
{
return $this->patterns === [];
return [] === $this->patterns;
}
public function matches(string $host, string $path): bool
{
if ($this->patterns === []) {
if ([] === $this->patterns) {
return false;
}
$host = strtolower($host);
foreach ($this->patterns as $entry) {
if ($entry['host'] !== null && $entry['host'] !== $host) {
if (null !== $entry['host'] && $entry['host'] !== $host) {
continue;
}
if (preg_match($entry['regex'], $path) === 1) {
if (1 === preg_match($entry['regex'], $path)) {
return true;
}
}
@@ -63,7 +63,7 @@ final readonly class PublicPathMatcher implements PublicPathMatcherInterface
*/
private function parse(string $publicPaths): array
{
if (trim($publicPaths) === '') {
if ('' === trim($publicPaths)) {
return [];
}
@@ -71,7 +71,7 @@ final readonly class PublicPathMatcher implements PublicPathMatcherInterface
foreach (explode(',', $publicPaths) as $raw) {
$entry = trim($raw);
if ($entry === '') {
if ('' === $entry) {
continue;
}
@@ -90,7 +90,7 @@ final readonly class PublicPathMatcher implements PublicPathMatcherInterface
}
$patterns[] = [
'host' => $host,
'host' => $host,
'regex' => $this->compilePattern($path),
];
}
@@ -109,33 +109,33 @@ final readonly class PublicPathMatcher implements PublicPathMatcherInterface
private function compilePattern(string $pattern): string
{
$regex = '';
$length = strlen($pattern);
$length = \strlen($pattern);
$i = 0;
while ($i < $length) {
// Check for ** (must be at current position)
if ($i + 1 < $length && $pattern[$i] === '*' && $pattern[$i + 1] === '*') {
if ($i + 1 < $length && '*' === $pattern[$i] && '*' === $pattern[$i + 1]) {
$i += 2;
if ($i >= $length) {
// ** at end of pattern: zero or more chars including /
$regex .= '.*';
} elseif ($pattern[$i] === '/') {
} elseif ('/' === $pattern[$i]) {
// /**/ in middle: zero or more intermediate segments
$regex .= '(?:.*/)?';
$i += 1; // skip the / after **
++$i; // skip the / after **
} else {
// ** not followed by / or end, treat as .*
$regex .= '.*';
}
} elseif ($pattern[$i] === '*') {
} elseif ('*' === $pattern[$i]) {
$regex .= '[^/]+';
$i += 1;
++$i;
} else {
$regex .= preg_quote($pattern[$i], '#');
$i += 1;
++$i;
}
}
return '#^' . $regex . '$#';
return '#^'.$regex.'$#';
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ trait GetTotpTrait
{
$otp = Factory::loadFromProvisioningUri(
$this->config->totpUri(),
$this->config->clock()
$this->config->clock(),
);
if ($otp instanceof TOTPInterface) {
return $otp;
+5 -6
View File
@@ -33,18 +33,16 @@ trait MakeNonceTrait
{
/* convert raw binary into base64url */
$nonce = rtrim(strtr(base64_encode(random_bytes(
static::NONCE_LENGTH
static::NONCE_LENGTH,
)), '+/', '-_'), '=');
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($nonce));
if ($nonceItem->isHit()) {
if ($retries < 1) {
$this->logger->error("aborting: multiple nonce collisions");
throw new HttpException(
Response::HTTP_INTERNAL_SERVER_ERROR,
'Internal Server Error'
);
$this->logger->error('aborting: multiple nonce collisions');
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
}
/* managed to have a collision, try again */
return $this->makeNonce($retries - 1);
}
@@ -53,6 +51,7 @@ trait MakeNonceTrait
$nonceItem->expiresAfter(static::NONCE_TTL);
$this->logger->debug("added nonce: $nonce");
$this->nonceCache->save($nonceItem);
return $nonce;
}
}
+4 -4
View File
@@ -30,7 +30,7 @@ trait StringTrait
$headers = ['Content-Type' => 'text/plain'];
$headerValue = $this->resolveRemoteUser($id, $config);
if ($headerValue !== null) {
if (null !== $headerValue) {
$headers['Remote-User'] = $headerValue;
}
@@ -45,9 +45,9 @@ trait StringTrait
{
return match ($config->remoteUserMode()) {
RemoteUserMode::Session => $id,
RemoteUserMode::Static => $config->remoteUserStatic(),
RemoteUserMode::Mapped => $config->remoteUserMap()[$id] ?? $id,
RemoteUserMode::None => null,
RemoteUserMode::Static => $config->remoteUserStatic(),
RemoteUserMode::Mapped => $config->remoteUserMap()[$id] ?? $id,
RemoteUserMode::None => null,
};
}
}
+5 -3
View File
@@ -15,7 +15,7 @@ use Psr\Clock\ClockInterface;
final readonly class Utilities
{
public function __construct(
private ClockInterface $clock,
private ClockInterface $clock,
private CacheItemPoolInterface $appPool,
) {
}
@@ -31,6 +31,7 @@ final readonly class Utilities
}
$this->showTotp($totp);
return $totp;
}
@@ -47,9 +48,10 @@ final readonly class Utilities
* we want this to keep forever, so a few hundred years should do it */
$totpItem->expiresAt(DateTimeImmutable::createFromFormat(
'Y-m-d',
AppConstants::FAR_FUTURE_DATE
AppConstants::FAR_FUTURE_DATE,
));
$this->appPool->save($totpItem);
return $totp;
}
@@ -64,7 +66,7 @@ final readonly class Utilities
loading TOTP, because the env is not set, please copy above into TOTP_URI
RAW,
FILE_APPEND
\FILE_APPEND,
);
}
}
+3
View File
@@ -11,6 +11,9 @@
".php-cs-fixer.dist.php"
]
},
"phpstan/phpstan": {
"version": "2.2.15"
},
"phpunit/phpunit": {
"version": "13.2",
"recipe": {
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<title>{{ env.title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
{{- include('_style.html.twig') -}}
</head>
<body id="preauth-body">
+46 -45
View File
@@ -42,7 +42,8 @@ final class AuthenticationFlowTest extends WebTestCase
/** base64url-encode a payload, matching the client-side JS / X-Preauth header. */
private function encodePayload(array $data): string
{
$json = json_encode($data, JSON_THROW_ON_ERROR);
$json = json_encode($data, \JSON_THROW_ON_ERROR);
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
}
@@ -53,16 +54,16 @@ final class AuthenticationFlowTest extends WebTestCase
bool $json = true,
): string {
return $this->encodePayload([
'id' => $id,
'id' => $id,
'token' => $token ?? $this->validTotpCode(),
'nonce' => $nonce,
'json' => $json,
'json' => $json,
]);
}
/* ── unauthenticated access ──────────────────────────────────────── */
public function testUnauthenticatedRequestShowsLoginPage(): void
public function test_unauthenticated_request_shows_login_page(): void
{
$client = static::createClient();
$client->request('GET', '/');
@@ -75,7 +76,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertSelectorExists('input[name="totp"]');
}
public function testLoginPageContainsGeneratedNonce(): void
public function test_login_page_contains_generated_nonce(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
@@ -86,7 +87,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonceInput);
}
public function testLoginFormDoesNotUsePostMethodWithoutAuthSubdomain(): void
public function test_login_form_does_not_use_post_method_without_auth_subdomain(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
@@ -99,7 +100,7 @@ final class AuthenticationFlowTest extends WebTestCase
/* ── successful TOTP login ────────────────────────────────────────── */
public function testSuccessfulTotpLoginViaHeaderSetsCookieAndRedirects(): void
public function test_successful_totp_login_via_header_sets_cookie_and_redirects(): void
{
$client = static::createClient();
@@ -111,10 +112,10 @@ final class AuthenticationFlowTest extends WebTestCase
// now submit a valid TOTP via the X-Preauth header
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice',
'id' => 'alice',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => true,
'json' => true,
]),
]);
@@ -132,7 +133,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertTrue($hasPreauthCookie, 'Expected a preauth cookie to be set after login');
}
public function testSuccessfulLoginReturnsJsonWhenJsonRequested(): void
public function test_successful_login_returns_json_when_json_requested(): void
{
$client = static::createClient();
@@ -141,10 +142,10 @@ final class AuthenticationFlowTest extends WebTestCase
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'bob',
'id' => 'bob',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => true,
'json' => true,
]),
]);
@@ -155,7 +156,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertSame('Login successful', $body['message']);
}
public function testSuccessfulLoginReturnsHtmlWhenJsonFalse(): void
public function test_successful_login_returns_html_when_json_false(): void
{
$client = static::createClient();
@@ -164,10 +165,10 @@ final class AuthenticationFlowTest extends WebTestCase
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'carol',
'id' => 'carol',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => false,
'json' => false,
]),
]);
@@ -176,7 +177,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertStringStartsWith('text/html', $response->headers->get('Content-Type'));
}
public function testAuthenticatedCookieAccessAfterLogin(): void
public function test_authenticated_cookie_access_after_login(): void
{
$client = static::createClient();
@@ -186,10 +187,10 @@ final class AuthenticationFlowTest extends WebTestCase
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'dave',
'id' => 'dave',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => true,
'json' => true,
]),
]);
@@ -214,7 +215,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertSame('dave', $response->headers->get('Remote-User'));
}
public function testScopeNoneReturnsPlainTextWithoutRedirect(): void
public function test_scope_none_returns_plain_text_without_redirect(): void
{
$client = static::createClient();
@@ -223,7 +224,7 @@ final class AuthenticationFlowTest extends WebTestCase
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'eve',
'id' => 'eve',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'scope' => 'none',
@@ -240,7 +241,7 @@ final class AuthenticationFlowTest extends WebTestCase
/* ── failed login ─────────────────────────────────────────────────── */
public function testFailedLoginReturnsUnauthorizedJsonWithError(): void
public function test_failed_login_returns_unauthorized_json_with_error(): void
{
$client = static::createClient();
@@ -249,10 +250,10 @@ final class AuthenticationFlowTest extends WebTestCase
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice',
'id' => 'alice',
'token' => '000000', // wrong code
'nonce' => $nonce,
'json' => true,
'json' => true,
]),
]);
@@ -266,7 +267,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertNotEmpty($body['nonce']);
}
public function testFailedLoginReturnsHtmlWhenJsonFalse(): void
public function test_failed_login_returns_html_when_json_false(): void
{
$client = static::createClient();
@@ -275,10 +276,10 @@ final class AuthenticationFlowTest extends WebTestCase
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice',
'id' => 'alice',
'token' => 'wrong-code',
'nonce' => $nonce,
'json' => false,
'json' => false,
]),
]);
@@ -288,7 +289,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertSelectorExists('form#preauth-form');
}
public function testFailedLoginWithSpentNonceIsRejected(): void
public function test_failed_login_with_spent_nonce_is_rejected(): void
{
$client = static::createClient();
@@ -298,10 +299,10 @@ final class AuthenticationFlowTest extends WebTestCase
// first: successful login consumes the nonce
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice',
'id' => 'alice',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => true,
'json' => true,
]),
]);
self::assertSame(303, $client->getResponse()->getStatusCode());
@@ -314,26 +315,26 @@ final class AuthenticationFlowTest extends WebTestCase
// reuse the same nonce — should fail even with a valid token
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice',
'id' => 'alice',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => true,
'json' => true,
]),
]);
self::assertSame(401, $client->getResponse()->getStatusCode());
}
public function testFailedLoginWithInvalidNonceIsRejected(): void
public function test_failed_login_with_invalid_nonce_is_rejected(): void
{
$client = static::createClient();
// skip fetching a real nonce; use one that was never stored
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice',
'id' => 'alice',
'token' => $this->validTotpCode(),
'nonce' => 'never-issued-nonce',
'json' => true,
'json' => true,
]),
]);
@@ -342,7 +343,7 @@ final class AuthenticationFlowTest extends WebTestCase
/* ── invalid payload ──────────────────────────────────────────────── */
public function testInvalidHeaderPayloadReturnsUnauthorized(): void
public function test_invalid_header_payload_returns_unauthorized(): void
{
$client = static::createClient();
@@ -354,7 +355,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertSame(401, $client->getResponse()->getStatusCode());
}
public function testPayloadWithMissingFieldsReturnsUnauthorized(): void
public function test_payload_with_missing_fields_returns_unauthorized(): void
{
$client = static::createClient();
@@ -370,7 +371,7 @@ final class AuthenticationFlowTest extends WebTestCase
/* ── invalid cookie ───────────────────────────────────────────────── */
public function testInvalidCookieIsClearedAndLoginPageShown(): void
public function test_invalid_cookie_is_cleared_and_login_page_shown(): void
{
$client = static::createClient();
@@ -388,7 +389,7 @@ final class AuthenticationFlowTest extends WebTestCase
true,
false,
'Strict',
)
),
);
$client->request('GET', 'https://localhost/');
@@ -399,7 +400,7 @@ final class AuthenticationFlowTest extends WebTestCase
// the stale cookie should be cleared
$cleared = false;
foreach ($response->headers->getCookies() as $cookie) {
if ($cookie->getName() === self::COOKIE_NAME && $cookie->isCleared()) {
if (self::COOKIE_NAME === $cookie->getName() && $cookie->isCleared()) {
$cleared = true;
}
}
@@ -408,7 +409,7 @@ final class AuthenticationFlowTest extends WebTestCase
/* ── backup code authentication ───────────────────────────────────── */
public function testBackupCodeAuthenticationWorks(): void
public function test_backup_code_authentication_works(): void
{
$client = static::createClient();
$container = $client->getContainer();
@@ -423,17 +424,17 @@ final class AuthenticationFlowTest extends WebTestCase
$client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'frank',
'id' => 'frank',
'token' => $codes[0],
'nonce' => $nonce,
'json' => true,
'json' => true,
]),
]);
self::assertSame(303, $client->getResponse()->getStatusCode());
}
public function testConsumedBackupCodeCannotBeReused(): void
public function test_consumed_backup_code_cannot_be_reused(): void
{
$client = static::createClient();
$container = $client->getContainer();
@@ -469,7 +470,7 @@ final class AuthenticationFlowTest extends WebTestCase
/* ── return URL handling ──────────────────────────────────────────── */
public function testSuccessfulLoginWithValidReturnUrl(): void
public function test_successful_login_with_valid_return_url(): void
{
$client = static::createClient();
@@ -488,7 +489,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertSame('https://example.com/app', $response->headers->get('Location'));
}
public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void
public function test_successful_login_with_invalid_return_falls_back_to_path(): void
{
$client = static::createClient();
+11 -10
View File
@@ -34,7 +34,8 @@ final class CacheControlFlowTest extends WebTestCase
private function encodePayload(array $data): string
{
$json = json_encode($data, JSON_THROW_ON_ERROR);
$json = json_encode($data, \JSON_THROW_ON_ERROR);
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
}
@@ -61,7 +62,7 @@ final class CacheControlFlowTest extends WebTestCase
/* ── login flow: nothing may be cached ────────────────────────────── */
public function testLoginPageIsNotCacheable(): void
public function test_login_page_is_not_cacheable(): void
{
$client = static::createClient();
$client->request('GET', '/');
@@ -71,7 +72,7 @@ final class CacheControlFlowTest extends WebTestCase
$this->assertNotCacheable($response);
}
public function testLoginPageFetchBypassesHttpCache(): void
public function test_login_page_fetch_bypasses_http_cache(): void
{
$client = static::createClient();
$client->request('GET', '/');
@@ -83,7 +84,7 @@ final class CacheControlFlowTest extends WebTestCase
self::assertStringContainsString('window.location.replace(', $content);
}
public function testFailedLoginIsNotCacheable(): void
public function test_failed_login_is_not_cacheable(): void
{
$client = static::createClient();
@@ -101,7 +102,7 @@ final class CacheControlFlowTest extends WebTestCase
$this->assertNotCacheable($response);
}
public function testSuccessfulLoginRedirectIsNotCacheable(): void
public function test_successful_login_redirect_is_not_cacheable(): void
{
$client = static::createClient();
@@ -121,7 +122,7 @@ final class CacheControlFlowTest extends WebTestCase
self::assertTrue($response->headers->has('Location'));
}
public function testLoginPageOnAnotherHostIsNotCacheable(): void
public function test_login_page_on_another_host_is_not_cacheable(): void
{
// the listener applies to every main response, not only the primary
// host; subdomain redirection itself is covered by InterceptListener
@@ -134,13 +135,13 @@ final class CacheControlFlowTest extends WebTestCase
$this->assertNotCacheable($response);
}
public function testRateLimitedResponseIsNotCacheable(): void
public function test_rate_limited_response_is_not_cacheable(): void
{
$client = static::createClient();
// the login limiter is raised for tests, so exercise the public
// limiter instead (test config: PUBLIC_BURST_COUNT=3)
for ($i = 0; $i < 4; $i++) {
for ($i = 0; $i < 4; ++$i) {
$client->request('GET', '/public/repo');
}
@@ -151,7 +152,7 @@ final class CacheControlFlowTest extends WebTestCase
/* ── 2xx grants: must stay untouched ──────────────────────────────── */
public function testAuthenticatedAccessResponseIsNotModifiedByAntiCachingHeaders(): void
public function test_authenticated_access_response_is_not_modified_by_anti_caching_headers(): void
{
$client = static::createClient();
@@ -176,7 +177,7 @@ final class CacheControlFlowTest extends WebTestCase
$this->assertCacheable($response);
}
public function testPublicAccessResponseIsNotModifiedByAntiCachingHeaders(): void
public function test_public_access_response_is_not_modified_by_anti_caching_headers(): void
{
$client = static::createClient();
$client->request('GET', '/public/repo');
+20 -18
View File
@@ -27,6 +27,7 @@ final class PublicAccessFlowTest extends WebTestCase
{
$client = parent::createClient($options, $server);
$client->disableReboot();
return $client;
}
@@ -37,13 +38,14 @@ final class PublicAccessFlowTest extends WebTestCase
private function encodePayload(array $data): string
{
$json = json_encode($data, JSON_THROW_ON_ERROR);
$json = json_encode($data, \JSON_THROW_ON_ERROR);
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
}
/* ── public path accessible without auth ───────────────────────────── */
public function testPublicPathAccessibleWithoutAuthentication(): void
public function test_public_path_accessible_without_authentication(): void
{
$client = static::createClient();
$client->request('GET', '/public/some-repo');
@@ -54,7 +56,7 @@ final class PublicAccessFlowTest extends WebTestCase
self::assertFalse($response->headers->has('Remote-User'));
}
public function testPublicPathWithQuerystringAccessible(): void
public function test_public_path_with_querystring_accessible(): void
{
$client = static::createClient();
$client->request('GET', '/public/repo?tab=issues&page=2');
@@ -62,7 +64,7 @@ final class PublicAccessFlowTest extends WebTestCase
self::assertSame(200, $client->getResponse()->getStatusCode());
}
public function testDeepPublicPathAccessible(): void
public function test_deep_public_path_accessible(): void
{
$client = static::createClient();
$client->request('GET', '/public/org/repo/issues/42');
@@ -72,7 +74,7 @@ final class PublicAccessFlowTest extends WebTestCase
/* ── non-public path requires auth ─────────────────────────────────── */
public function testNonPublicPathShowsLoginPage(): void
public function test_non_public_path_shows_login_page(): void
{
$client = static::createClient();
$client->request('GET', '/private/settings');
@@ -81,7 +83,7 @@ final class PublicAccessFlowTest extends WebTestCase
self::assertSelectorExists('form#preauth-form');
}
public function testRootPathShowsLoginPage(): void
public function test_root_path_shows_login_page(): void
{
$client = static::createClient();
$client->request('GET', '/');
@@ -89,7 +91,7 @@ final class PublicAccessFlowTest extends WebTestCase
self::assertSame(401, $client->getResponse()->getStatusCode());
}
public function testExactPublicPathWithoutSlashNotMatched(): void
public function test_exact_public_path_without_slash_not_matched(): void
{
// /public/** does NOT match /public (no trailing content)
$client = static::createClient();
@@ -100,17 +102,17 @@ final class PublicAccessFlowTest extends WebTestCase
/* ── rate limiting ─────────────────────────────────────────────────── */
public function testRateLimitEnforcedAfterBurstExceeded(): void
public function test_rate_limit_enforced_after_burst_exceeded(): void
{
$client = static::createClient();
// PUBLIC_BURST_COUNT=3 — first 3 requests succeed
for ($i = 0; $i < 3; $i++) {
for ($i = 0; $i < 3; ++$i) {
$client->request('GET', '/public/repo');
self::assertSame(
200,
$client->getResponse()->getStatusCode(),
"Request $i should have been allowed"
"Request $i should have been allowed",
);
}
@@ -125,12 +127,12 @@ final class PublicAccessFlowTest extends WebTestCase
/* ── authenticated user bypasses public rate limiter ───────────────── */
public function testAuthenticatedUserBypassesPublicRateLimit(): void
public function test_authenticated_user_bypasses_public_rate_limit(): void
{
$client = static::createClient();
// First, exhaust the public rate limiter
for ($i = 0; $i < 4; $i++) {
for ($i = 0; $i < 4; ++$i) {
$client->request('GET', '/public/repo');
}
// Confirm rate limit is in effect
@@ -145,10 +147,10 @@ final class PublicAccessFlowTest extends WebTestCase
$client->request('GET', '/private', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice',
'id' => 'alice',
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => true,
'json' => true,
]),
]);
self::assertSame(303, $client->getResponse()->getStatusCode());
@@ -166,7 +168,7 @@ final class PublicAccessFlowTest extends WebTestCase
/* ── 200 response has correct content type ─────────────────────────── */
public function testPublicAccessResponseIsPlainText(): void
public function test_public_access_response_is_plain_text(): void
{
$client = static::createClient();
$client->request('GET', '/public/repo');
@@ -178,12 +180,12 @@ final class PublicAccessFlowTest extends WebTestCase
/* ── 429 response renders error template ───────────────────────────── */
public function testRateLimitedResponseRendersErrorTemplate(): void
public function test_rate_limited_response_renders_error_template(): void
{
$client = static::createClient();
// Exhaust rate limit
for ($i = 0; $i < 4; $i++) {
for ($i = 0; $i < 4; ++$i) {
$client->request('GET', '/public/repo');
}
@@ -198,7 +200,7 @@ final class PublicAccessFlowTest extends WebTestCase
/* ── security headers still applied to public responses ────────────── */
public function testSecurityHeadersOnPublicAccess(): void
public function test_security_headers_on_public_access(): void
{
$client = static::createClient();
$client->request('GET', '/public/repo');
+37 -26
View File
@@ -4,13 +4,10 @@ declare(strict_types=1);
namespace App\Tests\Support;
use App\ConfigBag;
use App\Service\DomainManager;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use DateTimeImmutable;
use Symfony\Component\RateLimiter\LimiterInterface;
use Symfony\Component\RateLimiter\RateLimit;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Symfony\Component\RateLimiter\LimiterInterface;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
@@ -25,26 +22,27 @@ trait ListenerTestHelper
/** Build a Twig Environment pointed at the project's real templates. */
private function makeTwig(): Environment
{
$loader = new FilesystemLoader(dirname(__DIR__, 2) . '/templates');
$loader = new FilesystemLoader(\dirname(__DIR__, 2).'/templates');
$twig = new Environment($loader, ['strict_variables' => true]);
// the templates reference a global `env` object; supply one with the
// keys used by base/login/error/_script/_style
$twig->addGlobal('env', (object)[
'title' => 'Pre-Authentication System',
'bg_color' => '#029386',
'fg_color' => '#ffffff',
'error_color' => '#ffb16d',
'id_name' => 'Session ID',
'token_name' => 'Authentication Token',
'submit_name' => 'Submit',
'error_message' => 'Unsuccessful login attempt',
'teapot' => true,
'teapot_title' => "I'm a teapot",
'teapot_message' => 'I refuse to brew coffee',
'too_many_title' => 'Too many requests',
$twig->addGlobal('env', (object) [
'title' => 'Pre-Authentication System',
'bg_color' => '#029386',
'fg_color' => '#ffffff',
'error_color' => '#ffb16d',
'id_name' => 'Session ID',
'token_name' => 'Authentication Token',
'submit_name' => 'Submit',
'error_message' => 'Unsuccessful login attempt',
'teapot' => true,
'teapot_title' => "I'm a teapot",
'teapot_message' => 'I refuse to brew coffee',
'too_many_title' => 'Too many requests',
'too_many_message' => 'Try again later',
'debug' => 0,
'debug' => 0,
]);
return $twig;
}
@@ -55,10 +53,12 @@ trait ListenerTestHelper
private function makeRateLimiterFactory(int $remainingTokens): RateLimiterFactoryInterface
{
$limiter = $this->makeLimiter($remainingTokens);
return new class ($limiter) implements RateLimiterFactoryInterface {
return new class($limiter) implements RateLimiterFactoryInterface {
public function __construct(private LimiterInterface $limiter)
{
}
public function create(?string $key = null): LimiterInterface
{
return $this->limiter;
@@ -70,22 +70,26 @@ trait ListenerTestHelper
{
$rateLimit = new RateLimit(
$remainingTokens,
new \DateTimeImmutable('+10 seconds'),
new DateTimeImmutable('+10 seconds'),
$remainingTokens > 0,
10,
);
return new class ($rateLimit) implements LimiterInterface {
return new class($rateLimit) implements LimiterInterface {
public function __construct(private RateLimit $rateLimit)
{
}
public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation
{
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException();
}
public function consume(int $tokens = 1): RateLimit
{
return $this->rateLimit;
}
public function reset(): void
{
}
@@ -98,35 +102,42 @@ trait ListenerTestHelper
*/
private function makeCountingRateLimiterFactory(int $threshold): RateLimiterFactoryInterface
{
$limiter = new class ($threshold) implements LimiterInterface {
$limiter = new class($threshold) implements LimiterInterface {
private int $consumed = 0;
public function __construct(private int $threshold)
{
}
public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation
{
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException();
}
public function consume(int $tokens = 1): RateLimit
{
$this->consumed += $tokens;
$remaining = max(0, $this->threshold - $this->consumed);
return new RateLimit(
$remaining,
new \DateTimeImmutable('+10 seconds'),
new DateTimeImmutable('+10 seconds'),
$remaining > 0,
$this->threshold,
);
}
public function reset(): void
{
$this->consumed = 0;
}
};
return new class ($limiter) implements RateLimiterFactoryInterface {
return new class($limiter) implements RateLimiterFactoryInterface {
public function __construct(private LimiterInterface $limiter)
{
}
public function create(?string $key = null): LimiterInterface
{
return $this->limiter;
+6 -3
View File
@@ -5,11 +5,9 @@ declare(strict_types=1);
namespace App\Tests\Support;
use App\ConfigBag;
use App\Enum\RemoteUserMode;
use App\Utilities;
use DateTimeImmutable;
use OTPHP\TOTP;
use PHPUnit\Framework\TestCase;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Clock\ClockInterface as PsrClockInterface;
@@ -31,10 +29,12 @@ trait TotpTestHelper
private function frozenClock(): PsrClockInterface
{
$time = self::FROZEN_TIME;
return new class ($time) implements PsrClockInterface {
return new class($time) implements PsrClockInterface {
public function __construct(private string $time)
{
}
public function now(): DateTimeImmutable
{
return new DateTimeImmutable($this->time);
@@ -47,6 +47,7 @@ trait TotpTestHelper
{
$totp = TOTP::createFromSecret(self::TOTP_SECRET, $this->frozenClock());
$totp->setLabel('Test-TOTP');
return $totp->getProvisioningUri();
}
@@ -79,6 +80,7 @@ trait TotpTestHelper
): ConfigBag {
$clock = $this->frozenClock();
$utilities = $this->createUtilities($clock);
return new ConfigBag(
$utilities,
$clock,
@@ -107,6 +109,7 @@ trait TotpTestHelper
$item = $this->createStub(CacheItemInterface::class);
$item->method('isHit')->willReturn(false);
$cache->method('getItem')->willReturn($item);
return new Utilities($clock, $cache);
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ class TestKernel extends AppKernel
{
parent::build($container);
$container->addCompilerPass(new class () implements CompilerPassInterface {
$container->addCompilerPass(new class implements CompilerPassInterface {
public function process(ContainerBuilder $container): void
{
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage', 'publicRateLimitCache'] as $poolId) {
+5 -4
View File
@@ -5,18 +5,19 @@ declare(strict_types=1);
namespace App\Tests\Unit;
use App\Clock;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
final class ClockTest extends TestCase
{
public function testNowReturnsDateTimeImmutable(): void
public function test_now_returns_date_time_immutable(): void
{
$clock = new Clock();
$before = new \DateTimeImmutable();
$before = new DateTimeImmutable();
$now = $clock->now();
$after = new \DateTimeImmutable();
$after = new DateTimeImmutable();
self::assertInstanceOf(\DateTimeImmutable::class, $now);
self::assertInstanceOf(DateTimeImmutable::class, $now);
self::assertGreaterThanOrEqual($before->getTimestamp(), $now->getTimestamp());
self::assertLessThanOrEqual($after->getTimestamp(), $now->getTimestamp());
}
@@ -5,11 +5,11 @@ declare(strict_types=1);
namespace App\Tests\Unit\Command;
use App\Command\GenerateBackupCodesCommand;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use App\PersistCache;
use App\Service\BackupCodeInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Tester\CommandTester;
final class GenerateBackupCodesCommandTest extends TestCase
@@ -25,16 +25,17 @@ final class GenerateBackupCodesCommandTest extends TestCase
{
$manager = $this->createStub(BackupCodeInterface::class);
$manager->method('generate')->willReturn($generatedCodes);
return $manager;
}
public function testGenerateDefaultCountOutputsCodes(): void
public function test_generate_default_count_outputs_codes(): void
{
$codes = ['abc123', 'def456', 'ghi789', 'jkl012', 'mno345',
'pqr678', 'stu901', 'vwx234', 'yzA567', 'bCd890'];
'pqr678', 'stu901', 'vwx234', 'yzA567', 'bCd890'];
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub($codes),
$this->makePersistCache()
$this->makePersistCache(),
);
$command->setName('app:generate-backup-codes');
@@ -48,7 +49,7 @@ final class GenerateBackupCodesCommandTest extends TestCase
}
}
public function testGenerateSpecificCountPassesCountToManager(): void
public function test_generate_specific_count_passes_count_to_manager(): void
{
$manager = $this->createMock(BackupCodeInterface::class);
$manager->expects(self::once())
@@ -65,7 +66,7 @@ final class GenerateBackupCodesCommandTest extends TestCase
self::assertSame(0, $exit);
}
public function testDefaultCountArgumentIsTen(): void
public function test_default_count_argument_is_ten(): void
{
// the configured default for the count argument should be 10
$manager = $this->createMock(BackupCodeInterface::class);
@@ -84,7 +85,7 @@ final class GenerateBackupCodesCommandTest extends TestCase
$this->addToAssertionCount(1);
}
public function testBootsAndPersistsCache(): void
public function test_boots_and_persists_cache(): void
{
// PersistCache is final and can't be mocked, but we can verify the
// command runs end-to-end with a real instance; boot()/persist()
@@ -92,7 +93,7 @@ final class GenerateBackupCodesCommandTest extends TestCase
// without throwing.
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub(['code1']),
$this->makePersistCache()
$this->makePersistCache(),
);
$command->setName('app:generate-backup-codes');
@@ -102,25 +103,25 @@ final class GenerateBackupCodesCommandTest extends TestCase
self::assertSame(0, $exit);
}
public function testZeroCodesThrowsException(): void
public function test_zero_codes_throws_exception(): void
{
// count must be a positive integer — zero is rejected
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub([]),
$this->makePersistCache()
$this->makePersistCache(),
);
$command->setName('app:generate-backup-codes');
$tester = new CommandTester($command);
$this->expectException(\Symfony\Component\Console\Exception\InvalidArgumentException::class);
$this->expectException(InvalidArgumentException::class);
$tester->execute(['count' => 0]);
}
public function testCommandNameAndDescriptionAreConfigured(): void
public function test_command_name_and_description_are_configured(): void
{
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub(['dummy']),
$this->makePersistCache()
$this->makePersistCache(),
);
// configuring via the Application runs the protected configure()
$app = new \Symfony\Component\Console\Application();
+9 -10
View File
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Tests\Unit;
use App\ConfigBag;
use App\Enum\RemoteUserMode;
use App\Tests\Support\TotpTestHelper;
use PHPUnit\Framework\TestCase;
@@ -13,14 +12,14 @@ final class ConfigBagRemoteUserTest extends TestCase
{
use TotpTestHelper;
public function testDefaultRemoteUserModeIsSession(): void
public function test_default_remote_user_mode_is_session(): void
{
$config = $this->makeConfig();
self::assertSame(RemoteUserMode::Session, $config->remoteUserMode());
}
public function testStaticMode(): void
public function test_static_mode(): void
{
$config = $this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated');
@@ -28,7 +27,7 @@ final class ConfigBagRemoteUserTest extends TestCase
self::assertSame('authenticated', $config->remoteUserStatic());
}
public function testMappedMode(): void
public function test_mapped_mode(): void
{
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin,bob:user');
@@ -36,28 +35,28 @@ final class ConfigBagRemoteUserTest extends TestCase
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
}
public function testNoneMode(): void
public function test_none_mode(): void
{
$config = $this->makeConfig(remoteUserMode: 'none');
self::assertSame(RemoteUserMode::None, $config->remoteUserMode());
}
public function testInvalidModeFallsBackToSession(): void
public function test_invalid_mode_falls_back_to_session(): void
{
$config = $this->makeConfig(remoteUserMode: 'invalid-mode');
self::assertSame(RemoteUserMode::Session, $config->remoteUserMode());
}
public function testEmptyMapReturnsEmptyArray(): void
public function test_empty_map_returns_empty_array(): void
{
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: '');
self::assertSame([], $config->remoteUserMap());
}
public function testMapParsesWithWhitespace(): void
public function test_map_parses_with_whitespace(): void
{
$config = $this->makeConfig(
remoteUserMode: 'mapped',
@@ -67,7 +66,7 @@ final class ConfigBagRemoteUserTest extends TestCase
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
}
public function testMapIgnoresInvalidEntries(): void
public function test_map_ignores_invalid_entries(): void
{
$config = $this->makeConfig(
remoteUserMode: 'mapped',
@@ -77,7 +76,7 @@ final class ConfigBagRemoteUserTest extends TestCase
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
}
public function testMapPreservesColonsInValue(): void
public function test_map_preserves_colons_in_value(): void
{
$config = $this->makeConfig(
remoteUserMode: 'mapped',
+5 -5
View File
@@ -18,7 +18,7 @@ final class ConfigBagTest extends TestCase
$clock = $this->createStub(ClockInterface::class);
$cache = $this->createStub(CacheItemPoolInterface::class);
if ($totp !== null) {
if (null !== $totp) {
$item = $this->createStub(CacheItemInterface::class);
$item->method('isHit')->willReturn(true);
$item->method('get')->willReturn($totp);
@@ -31,7 +31,7 @@ final class ConfigBagTest extends TestCase
return new Utilities($clock, $cache);
}
public function testGettersWithExplicitValues(): void
public function test_getters_with_explicit_values(): void
{
$clock = $this->createStub(ClockInterface::class);
$utilities = $this->createUtilities();
@@ -61,7 +61,7 @@ final class ConfigBagTest extends TestCase
self::assertSame('Too Many!', $config->tooManyTitle());
}
public function testTotpUriFallsBackToUtilitiesWhenEmpty(): void
public function test_totp_uri_falls_back_to_utilities_when_empty(): void
{
$clock = $this->createStub(ClockInterface::class);
$utilities = $this->createUtilities('fallback-totp');
@@ -84,7 +84,7 @@ final class ConfigBagTest extends TestCase
self::assertSame('fallback-totp', $config->totpUri());
}
public function testIpTtlFallsBackToNullWhenZero(): void
public function test_ip_ttl_falls_back_to_null_when_zero(): void
{
$clock = $this->createStub(ClockInterface::class);
$utilities = $this->createUtilities();
@@ -107,7 +107,7 @@ final class ConfigBagTest extends TestCase
self::assertNull($config->ipTtl());
}
public function testIpTtlFallsBackToNullWhenNull(): void
public function test_ip_ttl_falls_back_to_null_when_null(): void
{
$clock = $this->createStub(ClockInterface::class);
$utilities = $this->createUtilities();
+38 -38
View File
@@ -16,7 +16,7 @@ final class PayloadTest extends TestCase
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
public function testDecodeValidBase64Url(): void
public function test_decode_valid_base64_url(): void
{
$data = json_encode([
'id' => 'testuser', 'token' => '123456', 'nonce' => 'abc123',
@@ -32,49 +32,49 @@ final class PayloadTest extends TestCase
self::assertSame(Scope::Cookie, $payload->scope);
}
public function testDecodeInvalidBase64UrlReturnsNull(): void
public function test_decode_invalid_base64_url_returns_null(): void
{
self::assertNull(Payload::decode('!!!not-valid-base64!!!'));
}
public function testDecodeNonObjectJsonReturnsNull(): void
public function test_decode_non_object_json_returns_null(): void
{
self::assertNull(Payload::decode(self::b64u('"just a string"')));
}
public function testDecodeInvalidJsonReturnsNull(): void
public function test_decode_invalid_json_returns_null(): void
{
// valid base64url but invalid JSON
self::assertNull(Payload::decode(self::b64u('{invalid json')));
}
public function testDecodeJsonArrayReturnsNull(): void
public function test_decode_json_array_returns_null(): void
{
self::assertNull(Payload::decode(self::b64u('[1,2,3]')));
}
public function testDecodeJsonNullReturnsNull(): void
public function test_decode_json_null_returns_null(): void
{
self::assertNull(Payload::decode(self::b64u('null')));
}
public function testDecodeJsonBooleanReturnsNull(): void
public function test_decode_json_boolean_returns_null(): void
{
self::assertNull(Payload::decode(self::b64u('true')));
self::assertNull(Payload::decode(self::b64u('false')));
}
public function testDecodeJsonNumberReturnsNull(): void
public function test_decode_json_number_returns_null(): void
{
self::assertNull(Payload::decode(self::b64u('42')));
}
public function testDecodeEmptyStringReturnsNull(): void
public function test_decode_empty_string_returns_null(): void
{
self::assertNull(Payload::decode(''));
}
public function testLoadWithValidInputBag(): void
public function test_load_with_valid_input_bag(): void
{
$input = new InputBag([
'username' => 'alice', 'nonce' => 'nonce123', 'totp' => '654321',
@@ -89,34 +89,34 @@ final class PayloadTest extends TestCase
self::assertSame(Scope::Cookie, $payload->scope);
}
public function testLoadMissingUsernameReturnsNull(): void
public function test_load_missing_username_returns_null(): void
{
$input = new InputBag(['nonce' => 'n', 'totp' => 't']);
self::assertNull(Payload::load($input));
}
public function testLoadMissingNonceReturnsNull(): void
public function test_load_missing_nonce_returns_null(): void
{
$input = new InputBag(['username' => 'u', 'totp' => 't']);
self::assertNull(Payload::load($input));
}
public function testLoadMissingTotpReturnsNull(): void
public function test_load_missing_totp_returns_null(): void
{
$input = new InputBag(['username' => 'u', 'nonce' => 'n']);
self::assertNull(Payload::load($input));
}
public function testLoadWithAllFieldsPresentButEmptyReturnsNull(): void
public function test_load_with_all_fields_present_but_empty_returns_null(): void
{
// has() returns true for all, but create() rejects empty values
$input = new InputBag(['username' => '', 'nonce' => '', 'totp' => '']);
self::assertNull(Payload::load($input));
}
public function testCreateWithValidData(): void
public function test_create_with_valid_data(): void
{
$data = (object)[
$data = (object) [
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
'json' => false, 'scope' => 'ip',
];
@@ -130,16 +130,16 @@ final class PayloadTest extends TestCase
self::assertSame(Scope::Ip, $payload->scope);
}
public function testCreateWithDefaultScope(): void
public function test_create_with_default_scope(): void
{
$data = (object)['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1'];
$data = (object) ['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1'];
$payload = Payload::create($data);
self::assertSame(Scope::Cookie, $payload->scope);
}
public function testCreateWithInvalidScopeFallsBackToCookie(): void
public function test_create_with_invalid_scope_falls_back_to_cookie(): void
{
$data = (object)[
$data = (object) [
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
'scope' => 'admin',
];
@@ -147,16 +147,16 @@ final class PayloadTest extends TestCase
self::assertSame(Scope::Cookie, $payload->scope);
}
public function testCreateWithMissingJsonDefaultsToTrue(): void
public function test_create_with_missing_json_defaults_to_true(): void
{
$data = (object)['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1'];
$data = (object) ['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1'];
$payload = Payload::create($data);
self::assertTrue($payload->json);
}
public function testCreateWithNoneScopeSetsJsonFalse(): void
public function test_create_with_none_scope_sets_json_false(): void
{
$data = (object)[
$data = (object) [
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
'json' => true, 'scope' => 'none',
];
@@ -165,37 +165,37 @@ final class PayloadTest extends TestCase
self::assertFalse($payload->json);
}
public function testCreateWithEmptyIdReturnsNull(): void
public function test_create_with_empty_id_returns_null(): void
{
$data = (object)['id' => '', 'token' => 't', 'nonce' => 'n'];
$data = (object) ['id' => '', 'token' => 't', 'nonce' => 'n'];
self::assertNull(Payload::create($data));
}
public function testCreateWithWhitespaceIdReturnsNull(): void
public function test_create_with_whitespace_id_returns_null(): void
{
$data = (object)['id' => ' ', 'token' => 't', 'nonce' => 'n'];
$data = (object) ['id' => ' ', 'token' => 't', 'nonce' => 'n'];
self::assertNull(Payload::create($data));
}
public function testCreateWithEmptyTokenReturnsNull(): void
public function test_create_with_empty_token_returns_null(): void
{
$data = (object)['id' => 'u', 'token' => '', 'nonce' => 'n'];
$data = (object) ['id' => 'u', 'token' => '', 'nonce' => 'n'];
self::assertNull(Payload::create($data));
}
public function testCreateWithEmptyNonceReturnsNull(): void
public function test_create_with_empty_nonce_returns_null(): void
{
$data = (object)['id' => 'u', 'token' => 't', 'nonce' => ''];
$data = (object) ['id' => 'u', 'token' => 't', 'nonce' => ''];
self::assertNull(Payload::create($data));
}
public function testCreateTrimsAndTruncatesFields(): void
public function test_create_trims_and_truncates_fields(): void
{
$long = str_repeat('a', 200);
$data = (object)[
'id' => ' ' . $long . ' ',
'token' => ' ' . $long . ' ',
'nonce' => ' ' . $long . ' ',
$data = (object) [
'id' => ' '.$long.' ',
'token' => ' '.$long.' ',
'nonce' => ' '.$long.' ',
];
$payload = Payload::create($data);
$expected = mb_substr($long, 0, 128);
@@ -204,7 +204,7 @@ final class PayloadTest extends TestCase
self::assertSame($expected, $payload->nonce);
}
public function testToString(): void
public function test_to_string(): void
{
$payload = new Payload();
$payload->id = 'u';
+3 -3
View File
@@ -9,21 +9,21 @@ use PHPUnit\Framework\TestCase;
final class ScopeTest extends TestCase
{
public function testCases(): void
public function test_cases(): void
{
self::assertSame('cookie', Scope::Cookie->value);
self::assertSame('ip', Scope::Ip->value);
self::assertSame('none', Scope::None->value);
}
public function testTryFromValid(): void
public function test_try_from_valid(): void
{
self::assertSame(Scope::Cookie, Scope::tryFrom('cookie'));
self::assertSame(Scope::Ip, Scope::tryFrom('ip'));
self::assertSame(Scope::None, Scope::tryFrom('none'));
}
public function testTryFromInvalid(): void
public function test_try_from_invalid(): void
{
self::assertNull(Scope::tryFrom('invalid'));
self::assertNull(Scope::tryFrom(''));
+19 -19
View File
@@ -12,7 +12,6 @@ use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
@@ -30,13 +29,14 @@ final class AcceptListenerTest extends TestCase
): AcceptListener {
$listener = new AcceptListener($pool, $domainManager, $config ?? $this->makeConfig());
$listener->setLogger(new NullLogger());
return $listener;
}
private function makeEvent(Request $request): RequestEvent
{
return new RequestEvent(
$this->createStub(\Symfony\Component\HttpKernel\HttpKernelInterface::class),
$this->createStub(HttpKernelInterface::class),
$request,
HttpKernelInterface::MAIN_REQUEST,
);
@@ -44,11 +44,11 @@ final class AcceptListenerTest extends TestCase
/* ── valid cookie session ─────────────────────────────────────────── */
public function testValidCookieSetsResponseWithRemoteUser(): void
public function test_valid_cookie_sets_response_with_remote_user(): void
{
$pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid);
$item = $pool->getItem('cookie_'.$ulid);
$item->set('alice');
$pool->save($item);
@@ -68,11 +68,11 @@ final class AcceptListenerTest extends TestCase
self::assertSame('text/plain', $response->headers->get('Content-Type'));
}
public function testValidCookieUsesAuthCookieNameWhenUsingCentralAuth(): void
public function test_valid_cookie_uses_auth_cookie_name_when_using_central_auth(): void
{
$pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid);
$item = $pool->getItem('cookie_'.$ulid);
$item->set('bob');
$pool->save($item);
@@ -91,7 +91,7 @@ final class AcceptListenerTest extends TestCase
/* ── negative cases ───────────────────────────────────────────────── */
public function testNoCookieSetsNoResponse(): void
public function test_no_cookie_sets_no_response(): void
{
$pool = new ArrayAdapter();
$domainManager = new DomainManager(false, '');
@@ -103,7 +103,7 @@ final class AcceptListenerTest extends TestCase
self::assertFalse($event->hasResponse());
}
public function testCookieWithoutSessionSetsNoResponse(): void
public function test_cookie_without_session_sets_no_response(): void
{
$pool = new ArrayAdapter();
$domainManager = new DomainManager(false, '');
@@ -118,7 +118,7 @@ final class AcceptListenerTest extends TestCase
self::assertFalse($event->hasResponse());
}
public function testEmptyCookieValueSetsNoResponse(): void
public function test_empty_cookie_value_sets_no_response(): void
{
$pool = new ArrayAdapter();
$domainManager = new DomainManager(false, '');
@@ -137,11 +137,11 @@ final class AcceptListenerTest extends TestCase
/* ── Remote-User header modes ─────────────────────────────────────── */
public function testRemoteUserSessionModeSendsSessionId(): void
public function test_remote_user_session_mode_sends_session_id(): void
{
$pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid);
$item = $pool->getItem('cookie_'.$ulid);
$item->set('alice');
$pool->save($item);
@@ -162,11 +162,11 @@ final class AcceptListenerTest extends TestCase
self::assertSame('alice', $event->getResponse()->headers->get('Remote-User'));
}
public function testRemoteUserStaticModeSendsFixedValue(): void
public function test_remote_user_static_mode_sends_fixed_value(): void
{
$pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid);
$item = $pool->getItem('cookie_'.$ulid);
$item->set('alice');
$pool->save($item);
@@ -187,11 +187,11 @@ final class AcceptListenerTest extends TestCase
self::assertSame('authenticated', $event->getResponse()->headers->get('Remote-User'));
}
public function testRemoteUserMappedModeSendsMappedValue(): void
public function test_remote_user_mapped_mode_sends_mapped_value(): void
{
$pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid);
$item = $pool->getItem('cookie_'.$ulid);
$item->set('alice');
$pool->save($item);
@@ -212,11 +212,11 @@ final class AcceptListenerTest extends TestCase
self::assertSame('admin', $event->getResponse()->headers->get('Remote-User'));
}
public function testRemoteUserMappedModeFallsBackToSessionIdWhenNotInMap(): void
public function test_remote_user_mapped_mode_falls_back_to_session_id_when_not_in_map(): void
{
$pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid);
$item = $pool->getItem('cookie_'.$ulid);
$item->set('unknown_user');
$pool->save($item);
@@ -237,11 +237,11 @@ final class AcceptListenerTest extends TestCase
self::assertSame('unknown_user', $event->getResponse()->headers->get('Remote-User'));
}
public function testRemoteUserNoneModeOmitsHeader(): void
public function test_remote_user_none_mode_omits_header(): void
{
$pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid);
$item = $pool->getItem('cookie_'.$ulid);
$item->set('alice');
$pool->save($item);
+5 -4
View File
@@ -22,6 +22,7 @@ final class AllowListenerTest extends TestCase
{
$listener = new AllowListener($pool, $config);
$listener->setLogger(new NullLogger());
return $listener;
}
@@ -34,7 +35,7 @@ final class AllowListenerTest extends TestCase
);
}
public function testValidIpSessionSetsResponseWithRemoteUser(): void
public function test_valid_ip_session_sets_response_with_remote_user(): void
{
$pool = new ArrayAdapter();
$item = $pool->getItem('ip_1.2.3.4');
@@ -55,7 +56,7 @@ final class AllowListenerTest extends TestCase
self::assertSame('text/plain', $response->headers->get('Content-Type'));
}
public function testNoIpSessionSetsNoResponse(): void
public function test_no_ip_session_sets_no_response(): void
{
$pool = new ArrayAdapter();
$config = $this->makeConfig(ipTtl: 1800);
@@ -68,7 +69,7 @@ final class AllowListenerTest extends TestCase
self::assertFalse($event->hasResponse());
}
public function testIpAccessDisabledSetsNoResponse(): void
public function test_ip_access_disabled_sets_no_response(): void
{
$pool = new ArrayAdapter();
// even though there's a stored session, ip access is disabled
@@ -86,7 +87,7 @@ final class AllowListenerTest extends TestCase
self::assertFalse($event->hasResponse());
}
public function testIpAccessDisabledDoesNotCheckCache(): void
public function test_ip_access_disabled_does_not_check_cache(): void
{
$pool = new ArrayAdapter();
$config = $this->makeConfig(ipTtl: 0);
+13 -12
View File
@@ -35,6 +35,7 @@ final class InterceptListenerTest extends TestCase
);
$listener->setLogger(new NullLogger());
$listener->setNonceCache($nonceCache ?? new ArrayAdapter());
return $listener;
}
@@ -49,7 +50,7 @@ final class InterceptListenerTest extends TestCase
/* ── central-auth redirect branch ─────────────────────────────────── */
public function testRedirectsToAuthSubdomainWhenHostMatchesBaseDomain(): void
public function test_redirects_to_auth_subdomain_when_host_matches_base_domain(): void
{
$domainManager = new DomainManager(true, 'auth.example.com');
$listener = $this->makeListener($domainManager);
@@ -68,7 +69,7 @@ final class InterceptListenerTest extends TestCase
self::assertStringContainsString(urlencode('https://app.example.com/dashboard'), $location);
}
public function testDoesNotRedirectWhenAlreadyOnAuthSubdomain(): void
public function test_does_not_redirect_when_already_on_auth_subdomain(): void
{
$domainManager = new DomainManager(true, 'auth.example.com');
$listener = $this->makeListener($domainManager);
@@ -86,7 +87,7 @@ final class InterceptListenerTest extends TestCase
/* ── login page rendering branch ──────────────────────────────────── */
public function testPresentsLoginPageWithUnauthorizedStatus(): void
public function test_presents_login_page_with_unauthorized_status(): void
{
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener($domainManager);
@@ -105,7 +106,7 @@ final class InterceptListenerTest extends TestCase
self::assertStringContainsString('name="nonce"', $content);
}
public function testGeneratedNonceIsStoredInCache(): void
public function test_generated_nonce_is_stored_in_cache(): void
{
$nonceCache = new ArrayAdapter();
$domainManager = new DomainManager(false, '');
@@ -123,10 +124,10 @@ final class InterceptListenerTest extends TestCase
}
}
// ArrayAdapter stores raw values; verify at least one item was saved
self::assertTrue(count($nonceCache->getValues()) > 0);
self::assertTrue(\count($nonceCache->getValues()) > 0);
}
public function testLoginTemplateUsesPostFormWhenOnAuthSubdomain(): void
public function test_login_template_uses_post_form_when_on_auth_subdomain(): void
{
$domainManager = new DomainManager(true, 'auth.example.com');
$listener = $this->makeListener($domainManager);
@@ -140,7 +141,7 @@ final class InterceptListenerTest extends TestCase
self::assertStringContainsString('method="post"', $content);
}
public function testLoginTemplateDoesNotUsePostFormWhenNotOnAuthSubdomain(): void
public function test_login_template_does_not_use_post_form_when_not_on_auth_subdomain(): void
{
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener($domainManager);
@@ -156,7 +157,7 @@ final class InterceptListenerTest extends TestCase
/* ── invalid cookie pruning ───────────────────────────────────────── */
public function testInvalidCookieIsClearedWhenPresent(): void
public function test_invalid_cookie_is_cleared_when_present(): void
{
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener($domainManager);
@@ -174,14 +175,14 @@ final class InterceptListenerTest extends TestCase
$cookies = $response->headers->getCookies();
$cleared = false;
foreach ($cookies as $cookie) {
if ($cookie->getName() === self::COOKIE_NAME && $cookie->isCleared()) {
if (self::COOKIE_NAME === $cookie->getName() && $cookie->isCleared()) {
$cleared = true;
}
}
self::assertTrue($cleared, 'Expected the invalid cookie to be cleared');
}
public function testNoCookieClearingWhenNoCookiePresent(): void
public function test_no_cookie_clearing_when_no_cookie_present(): void
{
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener($domainManager);
@@ -194,7 +195,7 @@ final class InterceptListenerTest extends TestCase
self::assertSame([], $response->headers->getCookies());
}
public function testInvalidCookieUsesAuthCookieNameWithCentralAuth(): void
public function test_invalid_cookie_uses_auth_cookie_name_with_central_auth(): void
{
$domainManager = new DomainManager(true, 'auth.example.com');
$listener = $this->makeListener($domainManager);
@@ -209,7 +210,7 @@ final class InterceptListenerTest extends TestCase
$response = $event->getResponse();
$cleared = false;
foreach ($response->headers->getCookies() as $cookie) {
if ($cookie->getName() === self::AUTH_COOKIE_NAME && $cookie->isCleared()) {
if (self::AUTH_COOKIE_NAME === $cookie->getName() && $cookie->isCleared()) {
$cleared = true;
}
}
+14 -13
View File
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Tests\Unit\Listener;
use App\Data\Payload;
use App\Enum\Scope;
use App\Listener\LoginListener;
use App\Service\DomainManager;
use App\Service\LoginInterface;
@@ -38,6 +37,7 @@ final class LoginListenerTest extends TestCase
);
$listener->setLogger(new NullLogger());
$listener->setNonceCache(new ArrayAdapter());
return $listener;
}
@@ -53,13 +53,14 @@ final class LoginListenerTest extends TestCase
/** Build a base64url-encoded X-Preauth header value for a payload. */
private function encodePayload(array $data): string
{
$json = json_encode($data, JSON_THROW_ON_ERROR);
$json = json_encode($data, \JSON_THROW_ON_ERROR);
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
}
/* ── no login attempt ─────────────────────────────────────────────── */
public function testNoHeaderAndNoPostReturnsEarlyWithoutResponse(): void
public function test_no_header_and_no_post_returns_early_without_response(): void
{
$listener = $this->makeListener();
@@ -70,7 +71,7 @@ final class LoginListenerTest extends TestCase
self::assertFalse($event->hasResponse());
}
public function testPostToNonAuthSubdomainReturnsEarlyWithoutResponse(): void
public function test_post_to_non_auth_subdomain_returns_early_without_response(): void
{
// POST only counts as a login attempt when on the auth subdomain
$domainManager = new DomainManager(true, 'auth.example.com');
@@ -85,7 +86,7 @@ final class LoginListenerTest extends TestCase
/* ── successful login via header ──────────────────────────────────── */
public function testSuccessfulLoginViaHeaderSetsResponseFromManager(): void
public function test_successful_login_via_header_sets_response_from_manager(): void
{
$expected = new Response('hi alice', 200, ['Remote-User' => 'alice']);
$loginManager = $this->createStub(LoginInterface::class);
@@ -106,7 +107,7 @@ final class LoginListenerTest extends TestCase
self::assertSame($expected, $event->getResponse());
}
public function testSuccessfulLoginViaPostToAuthSubdomain(): void
public function test_successful_login_via_post_to_auth_subdomain(): void
{
$expected = new Response('hi bob', 303, ['Location' => '/']);
$loginManager = $this->createStub(LoginInterface::class);
@@ -128,7 +129,7 @@ final class LoginListenerTest extends TestCase
/* ── failed login ─────────────────────────────────────────────────── */
public function testFailedLoginReturnsJsonErrorWithNewNonce(): void
public function test_failed_login_returns_json_error_with_new_nonce(): void
{
$loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn(null);
@@ -157,7 +158,7 @@ final class LoginListenerTest extends TestCase
self::assertSame('alice', $body['username']);
}
public function testFailedLoginHtmlResponseWhenJsonFalse(): void
public function test_failed_login_html_response_when_json_false(): void
{
$loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn(null);
@@ -179,7 +180,7 @@ final class LoginListenerTest extends TestCase
self::assertStringContainsString('<form', $response->getContent());
}
public function testFailedLoginOnAuthSubdomainUsesPostForm(): void
public function test_failed_login_on_auth_subdomain_uses_post_form(): void
{
$loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn(null);
@@ -205,7 +206,7 @@ final class LoginListenerTest extends TestCase
/* ── rate-limited (blocked) login ─────────────────────────────────── */
public function testRateLimitedLoginReturnsTeapotWhenTeapotEnabled(): void
public function test_rate_limited_login_returns_teapot_when_teapot_enabled(): void
{
$loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn(null);
@@ -232,7 +233,7 @@ final class LoginListenerTest extends TestCase
self::assertSame('Teapot', $body['message']);
}
public function testRateLimitedLoginReturnsTooManyRequestsWhenTeapotDisabled(): void
public function test_rate_limited_login_returns_too_many_requests_when_teapot_disabled(): void
{
$loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn(null);
@@ -265,7 +266,7 @@ final class LoginListenerTest extends TestCase
/* ── invalid payload handling ─────────────────────────────────────── */
public function testInvalidHeaderPayloadStillRecordsFailureAndResponds(): void
public function test_invalid_header_payload_still_records_failure_and_responds(): void
{
$loginManager = $this->createMock(LoginInterface::class);
// checkToken should not be called with a null payload
@@ -286,7 +287,7 @@ final class LoginListenerTest extends TestCase
self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()->getStatusCode());
}
public function testPostWithoutRequiredFieldsDoesNotAttemptLogin(): void
public function test_post_without_required_fields_does_not_attempt_login(): void
{
$loginManager = $this->createMock(LoginInterface::class);
$loginManager->expects(self::never())->method('checkToken');
@@ -41,6 +41,7 @@ final class PublicAccessListenerTest extends TestCase
$this->makeRateLimiterFactory($remainingTokens),
);
$listener->setLogger(new NullLogger());
return $listener;
}
@@ -55,7 +56,7 @@ final class PublicAccessListenerTest extends TestCase
/* ── feature disabled ──────────────────────────────────────────────── */
public function testNoPublicPathsReturnsWithoutResponse(): void
public function test_no_public_paths_returns_without_response(): void
{
$listener = $this->makeListener(publicPaths: '');
@@ -68,7 +69,7 @@ final class PublicAccessListenerTest extends TestCase
/* ── non-public path ───────────────────────────────────────────────── */
public function testNonPublicPathReturnsWithoutResponse(): void
public function test_non_public_path_returns_without_response(): void
{
$listener = $this->makeListener(publicPaths: '/public/**');
@@ -81,7 +82,7 @@ final class PublicAccessListenerTest extends TestCase
/* ── public path within rate limit ─────────────────────────────────── */
public function testPublicPathWithinRateLimitReturns200(): void
public function test_public_path_within_rate_limit_returns200(): void
{
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 10);
@@ -99,7 +100,7 @@ final class PublicAccessListenerTest extends TestCase
/* ── public path rate limited ──────────────────────────────────────── */
public function testPublicPathOverRateLimitReturns429(): void
public function test_public_path_over_rate_limit_returns429(): void
{
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 0);
@@ -114,7 +115,7 @@ final class PublicAccessListenerTest extends TestCase
self::assertTrue($response->headers->has('Retry-After'));
}
public function testRateLimitedResponseContainsErrorTemplate(): void
public function test_rate_limited_response_contains_error_template(): void
{
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 0);
@@ -129,7 +130,7 @@ final class PublicAccessListenerTest extends TestCase
/* ── auth subdomain is never public ────────────────────────────────── */
public function testAuthSubdomainRequestIsSkipped(): void
public function test_auth_subdomain_request_is_skipped(): void
{
$listener = $this->makeListener(
publicPaths: '/**',
@@ -147,7 +148,7 @@ final class PublicAccessListenerTest extends TestCase
/* ── query string is ignored ───────────────────────────────────────── */
public function testQueryStringIsIgnoredForPathMatching(): void
public function test_query_string_is_ignored_for_path_matching(): void
{
$listener = $this->makeListener(publicPaths: '/public', remainingTokens: 10);
@@ -161,7 +162,7 @@ final class PublicAccessListenerTest extends TestCase
/* ── domain-scoped paths ───────────────────────────────────────────── */
public function testDomainScopedPathMatchesCorrectHost(): void
public function test_domain_scoped_path_matches_correct_host(): void
{
$listener = $this->makeListener(publicPaths: 'code.example.com/public/**', remainingTokens: 10);
@@ -173,7 +174,7 @@ final class PublicAccessListenerTest extends TestCase
self::assertSame(Response::HTTP_OK, $event->getResponse()->getStatusCode());
}
public function testDomainScopedPathDoesNotMatchOtherHost(): void
public function test_domain_scoped_path_does_not_match_other_host(): void
{
$listener = $this->makeListener(publicPaths: 'code.example.com/public/**', remainingTokens: 10);
@@ -186,7 +187,7 @@ final class PublicAccessListenerTest extends TestCase
/* ── wildcard matching ─────────────────────────────────────────────── */
public function testSingleWildcardMatching(): void
public function test_single_wildcard_matching(): void
{
$listener = $this->makeListener(publicPaths: '/public/*', remainingTokens: 10);
@@ -198,7 +199,7 @@ final class PublicAccessListenerTest extends TestCase
self::assertSame(Response::HTTP_OK, $event->getResponse()->getStatusCode());
}
public function testSingleWildcardDoesNotMatchDeepPath(): void
public function test_single_wildcard_does_not_match_deep_path(): void
{
$listener = $this->makeListener(publicPaths: '/public/*', remainingTokens: 10);
@@ -211,7 +212,7 @@ final class PublicAccessListenerTest extends TestCase
/* ── 200 response includes remaining token count ───────────────────── */
public function testOkResponseIncludesRetryAfterHeader(): void
public function test_ok_response_includes_retry_after_header(): void
{
// The 200 response includes a Retry-After header showing remaining tokens
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 42);
+5 -5
View File
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Tests\Unit\Listener;
use App\Listener\RejectListener;
use App\Service\DomainManager;
use App\Tests\Support\ListenerTestHelper;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
@@ -28,6 +27,7 @@ final class RejectListenerTest extends TestCase
$this->makeRateLimiterFactory($remainingTokens),
);
$listener->setLogger(new NullLogger());
return $listener;
}
@@ -40,7 +40,7 @@ final class RejectListenerTest extends TestCase
);
}
public function testBlockedRequestReturnsTeapotWhenTeapotEnabled(): void
public function test_blocked_request_returns_teapot_when_teapot_enabled(): void
{
$listener = $this->makeListener(teapot: true, remainingTokens: 0);
@@ -54,7 +54,7 @@ final class RejectListenerTest extends TestCase
self::assertSame('text/html', $response->headers->get('Content-Type'));
}
public function testBlockedRequestReturnsTooManyRequestsWhenTeapotDisabled(): void
public function test_blocked_request_returns_too_many_requests_when_teapot_disabled(): void
{
$listener = $this->makeListener(teapot: false, remainingTokens: 0);
@@ -68,7 +68,7 @@ final class RejectListenerTest extends TestCase
self::assertSame('text/html', $response->headers->get('Content-Type'));
}
public function testUnblockedRequestSetsNoResponse(): void
public function test_unblocked_request_sets_no_response(): void
{
$listener = $this->makeListener(remainingTokens: 5);
@@ -80,7 +80,7 @@ final class RejectListenerTest extends TestCase
self::assertFalse($event->hasResponse());
}
public function testBlockedResponseContainsErrorTemplateContent(): void
public function test_blocked_response_contains_error_template_content(): void
{
$listener = $this->makeListener(teapot: true, remainingTokens: 0);
@@ -64,7 +64,7 @@ final class SecurityHeadersListenerTest extends TestCase
/* ── non-2xx: the login flow must not be cacheable ────────────────── */
public function testLoginPageResponseIsNotCacheable(): void
public function test_login_page_response_is_not_cacheable(): void
{
$listener = $this->makeListener();
$response = new Response('<form>login</form>', Response::HTTP_UNAUTHORIZED);
@@ -75,7 +75,7 @@ final class SecurityHeadersListenerTest extends TestCase
$this->assertNoStoreHeaders($response);
}
public function testRedirectResponseIsNotCacheable(): void
public function test_redirect_response_is_not_cacheable(): void
{
$listener = $this->makeListener();
$response = new Response('', Response::HTTP_SEE_OTHER, [
@@ -90,7 +90,7 @@ final class SecurityHeadersListenerTest extends TestCase
self::assertSame('https://example.com/dashboard', $response->headers->get('Location'));
}
public function testRateLimitedResponseIsNotCacheable(): void
public function test_rate_limited_response_is_not_cacheable(): void
{
$listener = $this->makeListener();
$response = new Response('<h1>teapot</h1>', Response::HTTP_I_AM_A_TEAPOT);
@@ -101,7 +101,7 @@ final class SecurityHeadersListenerTest extends TestCase
$this->assertNoStoreHeaders($response);
}
public function testServerErrorResponseIsNotCacheable(): void
public function test_server_error_response_is_not_cacheable(): void
{
$listener = $this->makeListener();
$response = new Response('error', Response::HTTP_INTERNAL_SERVER_ERROR);
@@ -114,7 +114,7 @@ final class SecurityHeadersListenerTest extends TestCase
/* ── 2xx: authenticated / public grants stay untouched ────────────── */
public function testSuccessfulAuthenticatedResponseIsNotTouched(): void
public function test_successful_authenticated_response_is_not_touched(): void
{
$listener = $this->makeListener();
$response = new Response('hi alice', Response::HTTP_OK, [
@@ -133,7 +133,7 @@ final class SecurityHeadersListenerTest extends TestCase
self::assertSame('alice', $response->headers->get('Remote-User'));
}
public function testSuccessfulResponseKeepsItsOwnCacheHeaders(): void
public function test_successful_response_keeps_its_own_cache_headers(): void
{
$listener = $this->makeListener();
$response = new Response('ok', Response::HTTP_OK, [
@@ -152,7 +152,7 @@ final class SecurityHeadersListenerTest extends TestCase
/* ── sub-requests ─────────────────────────────────────────────────── */
public function testSubRequestsAreSkipped(): void
public function test_sub_requests_are_skipped(): void
{
$listener = $this->makeListener();
$response = new Response('login', Response::HTTP_UNAUTHORIZED);
@@ -166,7 +166,7 @@ final class SecurityHeadersListenerTest extends TestCase
/* ── the pre-existing security headers ────────────────────────────── */
public function testSecurityHeadersAreApplied(): void
public function test_security_headers_are_applied(): void
{
$listener = $this->makeListener();
$response = new Response('<form>login</form>', Response::HTTP_UNAUTHORIZED);
@@ -180,7 +180,7 @@ final class SecurityHeadersListenerTest extends TestCase
self::assertSame('max-age=31536000', $response->headers->get('Strict-Transport-Security'));
}
public function testCspAllowsSameOriginConnectWhenInlineScriptIsUsed(): void
public function test_csp_allows_same_origin_connect_when_inline_script_is_used(): void
{
// not on the auth subdomain: the login form uses an inline fetch()
$listener = $this->makeListener('auth.example.com');
@@ -192,7 +192,7 @@ final class SecurityHeadersListenerTest extends TestCase
self::assertStringContainsString("connect-src 'self';", $response->headers->get('Content-Security-Policy'));
}
public function testCspDoesNotAllowConnectWhenOnAuthSubdomain(): void
public function test_csp_does_not_allow_connect_when_on_auth_subdomain(): void
{
// on the auth subdomain the form POSTs normally — no inline fetch
$listener = $this->makeListener('auth.example.com');
+26 -25
View File
@@ -14,10 +14,11 @@ final class MonitorCacheKeysTest extends TestCase
private function wrap(?ArrayAdapter $pool = null): MonitorCacheKeys
{
$pool ??= new ArrayAdapter();
return new MonitorCacheKeys($pool);
}
public function testConstructorInitializesEmptyPool(): void
public function test_constructor_initializes_empty_pool(): void
{
$monitor = $this->wrap();
@@ -25,7 +26,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertSame([], $monitor->getChanges());
}
public function testSaveAddsKeyAndTracksChange(): void
public function test_save_adds_key_and_tracks_change(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('alpha');
@@ -36,7 +37,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertSame(['alpha' => MonitorCacheKeys::UPDATED], $monitor->getChanges());
}
public function testSaveDeferredThenCommitAddsKey(): void
public function test_save_deferred_then_commit_adds_key(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('beta');
@@ -48,7 +49,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertSame(['beta' => MonitorCacheKeys::UPDATED], $monitor->getChanges());
}
public function testGetItemReturnsUnderlyingItem(): void
public function test_get_item_returns_underlying_item(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('mykey');
@@ -60,7 +61,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertSame('data', $fetched->get());
}
public function testGetItemsReturnsMultipleItems(): void
public function test_get_items_returns_multiple_items(): void
{
$monitor = $this->wrap();
$a = $monitor->getItem('a');
@@ -78,7 +79,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertSame(['a' => 1, 'b' => 2], $keys);
}
public function testHasItemReturnsTrueForExistingKey(): void
public function test_has_item_returns_true_for_existing_key(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('exists');
@@ -89,7 +90,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertFalse($monitor->hasItem('missing'));
}
public function testDeleteItemRemovesKeyAndTracksRemoval(): void
public function test_delete_item_removes_key_and_tracks_removal(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('doomed');
@@ -103,7 +104,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertFalse($monitor->hasItem('doomed'));
}
public function testDeleteItemOnMissingKeyIsNoop(): void
public function test_delete_item_on_missing_key_is_noop(): void
{
$monitor = $this->wrap();
@@ -113,7 +114,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertSame([], $monitor->getKeys());
}
public function testDeleteItemsRemovesMultipleKeys(): void
public function test_delete_items_removes_multiple_keys(): void
{
$monitor = $this->wrap();
foreach (['x', 'y', 'z'] as $key) {
@@ -130,7 +131,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertSame(MonitorCacheKeys::REMOVED, $changes['y']);
}
public function testDeleteItemsWithMissingKeysStillReturnsTrue(): void
public function test_delete_items_with_missing_keys_still_returns_true(): void
{
$monitor = $this->wrap();
@@ -139,7 +140,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertTrue($result);
}
public function testClearWipesPoolWhenNotEmpty(): void
public function test_clear_wipes_pool_when_not_empty(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('keep');
@@ -152,7 +153,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertSame([], $monitor->getKeys());
}
public function testClearIsNoopWhenEmpty(): void
public function test_clear_is_noop_when_empty(): void
{
$monitor = $this->wrap();
@@ -161,7 +162,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertTrue($result);
}
public function testMarkCleanResetsChangeList(): void
public function test_mark_clean_resets_change_list(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('temp');
@@ -176,14 +177,14 @@ final class MonitorCacheKeysTest extends TestCase
self::assertSame(['temp'], $monitor->getKeys());
}
public function testCommitPassesThrough(): void
public function test_commit_passes_through(): void
{
$monitor = $this->wrap();
self::assertTrue($monitor->commit());
}
public function testSaveKeyListThrowsOutOfBoundsException(): void
public function test_save_key_list_throws_out_of_bounds_exception(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('__key_list');
@@ -192,7 +193,7 @@ final class MonitorCacheKeysTest extends TestCase
$monitor->save($item);
}
public function testSaveChangeListThrowsOutOfBoundsException(): void
public function test_save_change_list_throws_out_of_bounds_exception(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('__chg_list');
@@ -201,7 +202,7 @@ final class MonitorCacheKeysTest extends TestCase
$monitor->save($item);
}
public function testDeleteKeyListThrowsOutOfBoundsException(): void
public function test_delete_key_list_throws_out_of_bounds_exception(): void
{
$monitor = $this->wrap();
@@ -209,7 +210,7 @@ final class MonitorCacheKeysTest extends TestCase
$monitor->deleteItem('__key_list');
}
public function testDeleteChangeListThrowsOutOfBoundsException(): void
public function test_delete_change_list_throws_out_of_bounds_exception(): void
{
$monitor = $this->wrap();
@@ -217,7 +218,7 @@ final class MonitorCacheKeysTest extends TestCase
$monitor->deleteItem('__chg_list');
}
public function testDeleteItemsWithKeyListThrowsOutOfBoundsException(): void
public function test_delete_items_with_key_list_throws_out_of_bounds_exception(): void
{
$monitor = $this->wrap();
@@ -225,7 +226,7 @@ final class MonitorCacheKeysTest extends TestCase
$monitor->deleteItems(['safe', '__key_list']);
}
public function testDeleteItemsWithChangeListThrowsOutOfBoundsException(): void
public function test_delete_items_with_change_list_throws_out_of_bounds_exception(): void
{
$monitor = $this->wrap();
@@ -233,7 +234,7 @@ final class MonitorCacheKeysTest extends TestCase
$monitor->deleteItems(['__chg_list']);
}
public function testSaveDeferredOnKeyListThrowsOutOfBoundsException(): void
public function test_save_deferred_on_key_list_throws_out_of_bounds_exception(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('safe');
@@ -247,7 +248,7 @@ final class MonitorCacheKeysTest extends TestCase
$monitor->saveDeferred($keyListItem);
}
public function testSaveDeferredOnChangeListThrowsOutOfBoundsException(): void
public function test_save_deferred_on_change_list_throws_out_of_bounds_exception(): void
{
$monitor = $this->wrap();
$changeListItem = $monitor->getItem('__chg_list');
@@ -256,7 +257,7 @@ final class MonitorCacheKeysTest extends TestCase
$monitor->saveDeferred($changeListItem);
}
public function testGetKeysReturnsEmptyArrayWhenKeyListMissing(): void
public function test_get_keys_returns_empty_array_when_key_list_missing(): void
{
// If the underlying pool loses its key list, getKeys should return []
$pool = new ArrayAdapter();
@@ -275,7 +276,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertSame([], $monitor2->getKeys());
}
public function testDeleteItemReturnsTrueForExistingKey(): void
public function test_delete_item_returns_true_for_existing_key(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('to-delete');
@@ -286,7 +287,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertNotContains('to-delete', $monitor->getKeys());
}
public function testDeleteItemsReturnsTrue(): void
public function test_delete_items_returns_true(): void
{
$monitor = $this->wrap();
foreach (['a', 'b', 'c'] as $key) {
+9 -9
View File
@@ -11,7 +11,7 @@ use Symfony\Component\Cache\Adapter\ArrayAdapter;
final class PersistCacheTest extends TestCase
{
public function testBootWithEmptyStorageIsNoop(): void
public function test_boot_with_empty_storage_is_noop(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -24,7 +24,7 @@ final class PersistCacheTest extends TestCase
self::assertSame([], $monitor->getKeys());
}
public function testBootLoadsFromStorageIntoCache(): void
public function test_boot_loads_from_storage_into_cache(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -47,7 +47,7 @@ final class PersistCacheTest extends TestCase
self::assertSame([], $cacheMonitor->getChanges());
}
public function testBootDoesNotReloadWhenCacheAlreadyWarm(): void
public function test_boot_does_not_reload_when_cache_already_warm(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -73,7 +73,7 @@ final class PersistCacheTest extends TestCase
self::assertNotContains('cookie_new', $monitor->getKeys());
}
public function testPersistWritesChangesToStorage(): void
public function test_persist_writes_changes_to_storage(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -95,7 +95,7 @@ final class PersistCacheTest extends TestCase
self::assertSame('user2', $storageMonitor->getItem('cookie_xyz')->get());
}
public function testPersistHandlesRemovals(): void
public function test_persist_handles_removals(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -121,7 +121,7 @@ final class PersistCacheTest extends TestCase
self::assertNotContains('cookie_to_remove', $storageMonitor->getKeys());
}
public function testPersistIsNoopWhenNoChanges(): void
public function test_persist_is_noop_when_no_changes(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -134,7 +134,7 @@ final class PersistCacheTest extends TestCase
self::assertSame([], $storageMonitor->getKeys());
}
public function testFullBootModifyPersistCycle(): void
public function test_full_boot_modify_persist_cycle(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -160,7 +160,7 @@ final class PersistCacheTest extends TestCase
self::assertSame('cycled-user', $monitor->getItem('cookie_cycle')->get());
}
public function testPersistHandlesMixedUpdatesAndRemovals(): void
public function test_persist_handles_mixed_updates_and_removals(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -194,7 +194,7 @@ final class PersistCacheTest extends TestCase
self::assertNotContains('cookie_remove', $storageMonitor->getKeys());
}
public function testMultipleBootModifyPersistCycles(): void
public function test_multiple_boot_modify_persist_cycles(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
+28 -26
View File
@@ -6,6 +6,7 @@ namespace App\Tests\Unit\Service;
use App\Service\BackupCodeManager;
use App\Tests\Support\TotpTestHelper;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
@@ -20,10 +21,11 @@ final class BackupCodeManagerTest extends TestCase
$manager = new BackupCodeManager($pool);
$manager->setConfig($this->makeConfig());
$manager->setLogger(new NullLogger());
return $manager;
}
public function testGenerateReturnsRequestedCount(): void
public function test_generate_returns_requested_count(): void
{
$manager = $this->makeManager();
@@ -37,7 +39,7 @@ final class BackupCodeManagerTest extends TestCase
}
}
public function testGenerateDefaultCount(): void
public function test_generate_default_count(): void
{
$manager = $this->makeManager();
@@ -46,7 +48,7 @@ final class BackupCodeManagerTest extends TestCase
self::assertCount(10, $codes);
}
public function testGenerateZeroReturnsEmptyArray(): void
public function test_generate_zero_returns_empty_array(): void
{
$manager = $this->makeManager();
@@ -55,7 +57,7 @@ final class BackupCodeManagerTest extends TestCase
self::assertSame([], $codes);
}
public function testGeneratedCodesAreStoredInCache(): void
public function test_generated_codes_are_stored_in_cache(): void
{
$pool = new ArrayAdapter();
$manager = $this->makeManager($pool);
@@ -64,15 +66,15 @@ final class BackupCodeManagerTest extends TestCase
// each code should be stored as a backup_ key
foreach ($codes as $code) {
$key = 'backup_' . strtolower($code);
$key = 'backup_'.strtolower($code);
// the manager uses makeCacheKey which sanitizes, but for alphanumeric it's identity
$item = $pool->getItem($key);
self::assertTrue($item->isHit(), "Expected cache hit for key: $key");
self::assertTrue($item->get(), "Expected code to be marked valid (true)");
self::assertTrue($item->get(), 'Expected code to be marked valid (true)');
}
}
public function testGeneratedCodesHaveFarFutureExpiry(): void
public function test_generated_codes_have_far_future_expiry(): void
{
$pool = new ArrayAdapter();
$manager = $this->makeManager($pool);
@@ -80,12 +82,12 @@ final class BackupCodeManagerTest extends TestCase
$codes = $manager->generate(1);
$code = $codes[0];
$item = $pool->getItem('backup_' . strtolower($code));
$item = $pool->getItem('backup_'.strtolower($code));
$expiry = $item->getMetadata()['expiry'];
self::assertGreaterThan((new \DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry);
self::assertGreaterThan((new DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry);
}
public function testVerifyAndConsumeValidCode(): void
public function test_verify_and_consume_valid_code(): void
{
$manager = $this->makeManager();
$codes = $manager->generate(2);
@@ -95,7 +97,7 @@ final class BackupCodeManagerTest extends TestCase
self::assertTrue($manager->verifyAndConsume($code));
}
public function testVerifyAndConsumeMarksCodeAsUsed(): void
public function test_verify_and_consume_marks_code_as_used(): void
{
$pool = new ArrayAdapter();
$manager = $this->makeManager($pool);
@@ -109,14 +111,14 @@ final class BackupCodeManagerTest extends TestCase
self::assertFalse($manager->verifyAndConsume($code));
}
public function testVerifyAndConsumeInvalidCode(): void
public function test_verify_and_consume_invalid_code(): void
{
$manager = $this->makeManager();
self::assertFalse($manager->verifyAndConsume('nonexistent_code'));
}
public function testVerifyAndConsumeIsCaseInsensitive(): void
public function test_verify_and_consume_is_case_insensitive(): void
{
$manager = $this->makeManager();
$codes = $manager->generate(1);
@@ -126,17 +128,17 @@ final class BackupCodeManagerTest extends TestCase
self::assertTrue($manager->verifyAndConsume(strtoupper($code)));
}
public function testVerifyAndConsumeStripsInvalidCharacters(): void
public function test_verify_and_consume_strips_invalid_characters(): void
{
$manager = $this->makeManager();
$codes = $manager->generate(1);
$code = $codes[0];
// inject spaces and special chars — should be stripped
self::assertTrue($manager->verifyAndConsume(' ' . $code . '!!'));
self::assertTrue($manager->verifyAndConsume(' '.$code.'!!'));
}
public function testExpireRemovesAllBackupCodes(): void
public function test_expire_removes_all_backup_codes(): void
{
$pool = new ArrayAdapter();
$manager = $this->makeManager($pool);
@@ -146,11 +148,11 @@ final class BackupCodeManagerTest extends TestCase
// all backup keys should be gone
foreach ($codes as $code) {
self::assertFalse($pool->hasItem('backup_' . strtolower($code)));
self::assertFalse($pool->hasItem('backup_'.strtolower($code)));
}
}
public function testExpireWhenNoBackupCodesIsNoop(): void
public function test_expire_when_no_backup_codes_is_noop(): void
{
$pool = new ArrayAdapter();
$manager = $this->makeManager($pool);
@@ -162,7 +164,7 @@ final class BackupCodeManagerTest extends TestCase
self::assertTrue(true);
}
public function testExpireRemovesOnlyBackupPrefixedKeys(): void
public function test_expire_removes_only_backup_prefixed_keys(): void
{
$pool = new ArrayAdapter();
$manager = $this->makeManager($pool);
@@ -181,11 +183,11 @@ final class BackupCodeManagerTest extends TestCase
// backup keys are gone
foreach ($codes as $code) {
self::assertFalse($pool->hasItem('backup_' . strtolower($code)));
self::assertFalse($pool->hasItem('backup_'.strtolower($code)));
}
}
public function testVerifyAndConsumeEmptyStringReturnsFalse(): void
public function test_verify_and_consume_empty_string_returns_false(): void
{
$manager = $this->makeManager();
@@ -193,7 +195,7 @@ final class BackupCodeManagerTest extends TestCase
self::assertFalse($manager->verifyAndConsume(''));
}
public function testVerifyAndConsumeCodeWithValueFalseReturnsFalse(): void
public function test_verify_and_consume_code_with_value_false_returns_false(): void
{
$pool = new ArrayAdapter();
$manager = $this->makeManager($pool);
@@ -204,7 +206,7 @@ final class BackupCodeManagerTest extends TestCase
self::assertTrue($manager->verifyAndConsume($code));
// the code is now marked as false (used); isHit is true but get() is false
$key = 'backup_' . strtolower($code);
$key = 'backup_'.strtolower($code);
$item = $pool->getItem($key);
self::assertTrue($item->isHit());
self::assertFalse($item->get());
@@ -213,7 +215,7 @@ final class BackupCodeManagerTest extends TestCase
self::assertFalse($manager->verifyAndConsume($code));
}
public function testGenerateProducesUniqueCodes(): void
public function test_generate_produces_unique_codes(): void
{
$manager = $this->makeManager();
@@ -223,13 +225,13 @@ final class BackupCodeManagerTest extends TestCase
self::assertCount(50, array_unique($codes), 'All generated codes should be unique');
}
public function testGenerateCodeLengthIsDigitsPlusTwo(): void
public function test_generate_code_length_is_digits_plus_two(): void
{
$manager = $this->makeManager();
$codes = $manager->generate(1);
// default TOTP digits is 6, so code length should be 6 + 2 = 8
self::assertSame(8, strlen($codes[0]));
self::assertSame(8, \strlen($codes[0]));
}
}
+34 -34
View File
@@ -16,42 +16,42 @@ final class DomainManagerTest extends TestCase
/* ── authBase / getAuthSubdomain ─────────────────────────────────────── */
public function testAuthBaseIsNullWhenSubdomainRedirectIsDisabled(): void
public function test_auth_base_is_null_when_subdomain_redirect_is_disabled(): void
{
$manager = $this->createManager(false, 'auth.example.com');
self::assertNull($manager->authBase());
self::assertNull($manager->getAuthSubdomain());
}
public function testAuthBaseIsNullWhenAuthSubdomainIsEmpty(): void
public function test_auth_base_is_null_when_auth_subdomain_is_empty(): void
{
$manager = $this->createManager(true, '');
self::assertNull($manager->authBase());
self::assertNull($manager->getAuthSubdomain());
}
public function testAuthBaseExtractsSimpleDomain(): void
public function test_auth_base_extracts_simple_domain(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertSame('example.com', $manager->authBase());
self::assertSame('auth.example.com', $manager->getAuthSubdomain());
}
public function testAuthBaseExtractsMultiPartTld(): void
public function test_auth_base_extracts_multi_part_tld(): void
{
$manager = $this->createManager(true, 'auth.example.co.uk');
self::assertSame('example.co.uk', $manager->authBase());
self::assertSame('auth.example.co.uk', $manager->getAuthSubdomain());
}
public function testAuthBaseIsNullForLocalhostAuth(): void
public function test_auth_base_is_null_for_localhost_auth(): void
{
$manager = $this->createManager(true, 'localhost');
self::assertNull($manager->authBase());
self::assertNull($manager->getAuthSubdomain());
}
public function testAuthBaseIsNullForIpAuth(): void
public function test_auth_base_is_null_for_ip_auth(): void
{
$manager = $this->createManager(true, '192.168.1.1');
self::assertNull($manager->authBase());
@@ -60,42 +60,42 @@ final class DomainManagerTest extends TestCase
/* ── validReturn ──────────────────────────────────────────────────────── */
public function testValidReturnAcceptsAnyUrlWhenNoSubdomain(): void
public function test_valid_return_accepts_any_url_when_no_subdomain(): void
{
$manager = $this->createManager(false, '');
self::assertTrue($manager->validReturn('https://evil.com/page'));
self::assertTrue($manager->validReturn('https://example.com/ok'));
}
public function testValidReturnRejectsInvalidUrl(): void
public function test_valid_return_rejects_invalid_url(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->validReturn('not-a-url'));
self::assertFalse($manager->validReturn(''));
}
public function testValidReturnAcceptsSameBaseDomain(): void
public function test_valid_return_accepts_same_base_domain(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertTrue($manager->validReturn('https://app.example.com/dashboard'));
self::assertTrue($manager->validReturn('https://example.com/'));
}
public function testValidReturnRejectsDifferentBaseDomain(): void
public function test_valid_return_rejects_different_base_domain(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->validReturn('https://evil.com/phish'));
self::assertFalse($manager->validReturn('https://other-example.com/'));
}
public function testValidReturnHandlesCoUkTld(): void
public function test_valid_return_handles_co_uk_tld(): void
{
$manager = $this->createManager(true, 'auth.example.co.uk');
self::assertTrue($manager->validReturn('https://www.example.co.uk/'));
self::assertFalse($manager->validReturn('https://example.com/'));
}
public function testValidReturnRejectsUrlWithoutHost(): void
public function test_valid_return_rejects_url_without_host(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->validReturn('mailto:test@example.com'));
@@ -103,47 +103,47 @@ final class DomainManagerTest extends TestCase
/* ── matchesAuth ──────────────────────────────────────────────────────── */
public function testMatchesAuthIsFalseWhenSubdomainRedirectDisabled(): void
public function test_matches_auth_is_false_when_subdomain_redirect_disabled(): void
{
$manager = $this->createManager(false, 'auth.example.com');
self::assertFalse($manager->matchesAuth('example.com'));
self::assertFalse($manager->matchesAuth('app.example.com'));
}
public function testMatchesAuthIsFalseWhenAuthSubdomainIsEmpty(): void
public function test_matches_auth_is_false_when_auth_subdomain_is_empty(): void
{
$manager = $this->createManager(true, '');
self::assertFalse($manager->matchesAuth('example.com'));
}
public function testMatchesAuthMatchesSameBaseDomain(): void
public function test_matches_auth_matches_same_base_domain(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertTrue($manager->matchesAuth('example.com'));
self::assertTrue($manager->matchesAuth('app.example.com'));
}
public function testMatchesAuthRejectsDifferentBaseDomain(): void
public function test_matches_auth_rejects_different_base_domain(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->matchesAuth('evil.com'));
self::assertFalse($manager->matchesAuth('example.org'));
}
public function testMatchesAuthHandlesMultiPartTld(): void
public function test_matches_auth_handles_multi_part_tld(): void
{
$manager = $this->createManager(true, 'auth.example.co.uk');
self::assertTrue($manager->matchesAuth('www.example.co.uk'));
self::assertFalse($manager->matchesAuth('example.com'));
}
public function testMatchesAuthRejectsIpHost(): void
public function test_matches_auth_rejects_ip_host(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->matchesAuth('192.168.1.1'));
}
public function testMatchesAuthRejectsLocalhost(): void
public function test_matches_auth_rejects_localhost(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->matchesAuth('localhost'));
@@ -151,13 +151,13 @@ final class DomainManagerTest extends TestCase
/* ── baseDomain edge cases via matchesAuth ────────────────────────────── */
public function testMatchesAuthWithDeepSubdomain(): void
public function test_matches_auth_with_deep_subdomain(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertTrue($manager->matchesAuth('a.b.c.example.com'));
}
public function testMatchesAuthWithTwoPartDomain(): void
public function test_matches_auth_with_two_part_domain(): void
{
/* for a 2-part auth subdomain, the baseDomain retains both parts */
$manager = $this->createManager(true, 'auth.local');
@@ -169,7 +169,7 @@ final class DomainManagerTest extends TestCase
/* ── TLD table coverage ──────────────────────────────────────────────── */
public function testMatchesAuthWithComAuTld(): void
public function test_matches_auth_with_com_au_tld(): void
{
// com.au IS in the TLD table (au => [com,...], so *.com.au IS multi-part
$manager = $this->createManager(true, 'auth.example.com.au');
@@ -178,7 +178,7 @@ final class DomainManagerTest extends TestCase
self::assertFalse($manager->matchesAuth('example.com'));
}
public function testMatchesAuthWithCoJpTld(): void
public function test_matches_auth_with_co_jp_tld(): void
{
// co.jp IS in the TLD table (jp => [co,...], so *.co.jp IS multi-part
$manager = $this->createManager(true, 'auth.example.co.jp');
@@ -186,7 +186,7 @@ final class DomainManagerTest extends TestCase
self::assertTrue($manager->matchesAuth('www.example.co.jp'));
}
public function testMatchesAuthWithComBrTld(): void
public function test_matches_auth_with_com_br_tld(): void
{
// com.br: TLD table has br => [com,...], so *.com.br IS multi-part
$manager = $this->createManager(true, 'auth.example.com.br');
@@ -194,7 +194,7 @@ final class DomainManagerTest extends TestCase
self::assertTrue($manager->matchesAuth('app.example.com.br'));
}
public function testMatchesAuthWithCoNzTld(): void
public function test_matches_auth_with_co_nz_tld(): void
{
// co.nz is NOT in the TLD table (nz => [co,net,org], so *.co.nz IS multi-part)
$manager = $this->createManager(true, 'auth.example.co.nz');
@@ -202,7 +202,7 @@ final class DomainManagerTest extends TestCase
self::assertTrue($manager->matchesAuth('sub.example.co.nz'));
}
public function testMatchesAuthWithComMxTld(): void
public function test_matches_auth_with_com_mx_tld(): void
{
// com.mx is NOT in the TLD table (mx => [com,net,org], so *.com.mx IS multi-part)
$manager = $this->createManager(true, 'auth.example.com.mx');
@@ -210,7 +210,7 @@ final class DomainManagerTest extends TestCase
self::assertTrue($manager->matchesAuth('app.example.com.mx'));
}
public function testMatchesAuthWithCoInTld(): void
public function test_matches_auth_with_co_in_tld(): void
{
// co.in: in => [co,...], so *.co.in IS multi-part
$manager = $this->createManager(true, 'auth.example.co.in');
@@ -218,7 +218,7 @@ final class DomainManagerTest extends TestCase
self::assertTrue($manager->matchesAuth('app.example.co.in'));
}
public function testMatchesAuthWithBrComTld(): void
public function test_matches_auth_with_br_com_tld(): void
{
// br.com: TLD table has com => [br], so *.br.com IS multi-part
$manager = $this->createManager(true, 'auth.example.br.com');
@@ -226,7 +226,7 @@ final class DomainManagerTest extends TestCase
self::assertTrue($manager->matchesAuth('app.example.br.com'));
}
public function testSimpleTldNotTreatedAsMultiPart(): void
public function test_simple_tld_not_treated_as_multi_part(): void
{
// example.com is a standard 2-part domain, not multi-part
$manager = $this->createManager(true, 'auth.example.com');
@@ -237,7 +237,7 @@ final class DomainManagerTest extends TestCase
/* ── baseDomain edge cases ───────────────────────────────────────────── */
public function testMatchesAuthWithSingleLabelHost(): void
public function test_matches_auth_with_single_label_host(): void
{
// a single-label domain (not localhost, not IP) has baseLength 1
// so 'myhost' has baseDomain 'myhost', while 'auth.local' has base 'auth.local'
@@ -249,25 +249,25 @@ final class DomainManagerTest extends TestCase
self::assertTrue($manager->matchesAuth('app.auth.local'));
}
public function testMatchesAuthWithEmptyStringHost(): void
public function test_matches_auth_with_empty_string_host(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->matchesAuth(''));
}
public function testValidReturnAcceptsUrlWithPort(): void
public function test_valid_return_accepts_url_with_port(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertTrue($manager->validReturn('https://example.com:8080/path'));
}
public function testValidReturnAcceptsUrlWithoutPath(): void
public function test_valid_return_accepts_url_without_path(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertTrue($manager->validReturn('https://example.com'));
}
public function testValidReturnRejectsDifferentDomainWithPort(): void
public function test_valid_return_rejects_different_domain_with_port(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->validReturn('https://evil.com:8080/path'));
+37 -31
View File
@@ -8,21 +8,22 @@ use App\Data\Payload;
use App\Enum\Scope;
use App\Service\BackupCodeInterface;
use App\Service\DomainManager;
use App\Trait\StringTrait;
use App\Service\LoginManager;
use App\Tests\Support\TotpTestHelper;
use App\Trait\StringTrait;
use PHPUnit\Framework\TestCase;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\NullLogger;
use ReflectionProperty;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
final class LoginManagerTest extends TestCase
{
use TotpTestHelper;
use StringTrait;
use TotpTestHelper;
private ArrayAdapter $pool;
private BackupCodeInterface $backupCodeManager;
@@ -41,6 +42,7 @@ final class LoginManagerTest extends TestCase
$manager->setConfig($this->makeConfig(ipTtl: $ipTtl));
$manager->setLogger(new NullLogger());
$manager->setNonceCache(new ArrayAdapter());
return $manager;
}
@@ -60,13 +62,14 @@ final class LoginManagerTest extends TestCase
$payload->nonce = $nonce;
$payload->json = true;
$payload->scope = $scope;
return $payload;
}
/** Inject a nonce directly into the manager's nonce cache. */
private function insertNonce(LoginManager $manager, string $nonce): string
{
$reflection = new \ReflectionProperty(LoginManager::class, 'nonceCache');
$reflection = new ReflectionProperty(LoginManager::class, 'nonceCache');
$nonceCache = $reflection->getValue($manager);
$key = $this->makeCacheKey($nonce);
@@ -77,7 +80,7 @@ final class LoginManagerTest extends TestCase
return $nonce;
}
public function testCheckTokenReturnsNullForInvalidTotp(): void
public function test_check_token_returns_null_for_invalid_totp(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, token: 'wrong-code');
@@ -89,7 +92,7 @@ final class LoginManagerTest extends TestCase
self::assertNull($manager->checkToken($payload, $request));
}
public function testCheckTokenReturnsNullForSpentNonce(): void
public function test_check_token_returns_null_for_spent_nonce(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager);
@@ -97,7 +100,7 @@ final class LoginManagerTest extends TestCase
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
// spend the nonce first (use the same cache key the manager does)
$reflection = new \ReflectionProperty(LoginManager::class, 'nonceCache');
$reflection = new ReflectionProperty(LoginManager::class, 'nonceCache');
$nonceCache = $reflection->getValue($manager);
$nonceItem = $nonceCache->getItem($this->makeCacheKey('test-nonce-123'));
$nonceItem->set(false);
@@ -108,7 +111,7 @@ final class LoginManagerTest extends TestCase
self::assertNull($manager->checkToken($payload, $request));
}
public function testCheckTokenReturnsNullForMissingNonce(): void
public function test_check_token_returns_null_for_missing_nonce(): void
{
$manager = $this->makeLoginManager();
@@ -126,7 +129,7 @@ final class LoginManagerTest extends TestCase
self::assertNull($manager->checkToken($payload, $request));
}
public function testSuccessfulTotpLoginWithCookieScopeReturnsRedirect(): void
public function test_successful_totp_login_with_cookie_scope_returns_redirect(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
@@ -143,7 +146,7 @@ final class LoginManagerTest extends TestCase
self::assertTrue($response->headers->has('Set-Cookie'));
}
public function testSuccessfulLoginWithNoneScopeReturnsPlainResponse(): void
public function test_successful_login_with_none_scope_returns_plain_response(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::None);
@@ -162,7 +165,7 @@ final class LoginManagerTest extends TestCase
self::assertFalse($response->headers->has('Location'));
}
public function testSuccessfulLoginSetsRemoteUserHeader(): void
public function test_successful_login_sets_remote_user_header(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, id: 'alice', scope: Scope::None);
@@ -177,7 +180,7 @@ final class LoginManagerTest extends TestCase
self::assertSame('alice', $response->headers->get('Remote-User'));
}
public function testSuccessfulLoginJsonResponse(): void
public function test_successful_login_json_response(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie, token: null);
@@ -195,7 +198,7 @@ final class LoginManagerTest extends TestCase
self::assertSame('Login successful', $body['message']);
}
public function testSuccessfulLoginHtmlResponse(): void
public function test_successful_login_html_response(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
@@ -211,7 +214,7 @@ final class LoginManagerTest extends TestCase
self::assertSame('text/html', $response->headers->get('Content-Type'));
}
public function testSuccessfulLoginWithReturnUrl(): void
public function test_successful_login_with_return_url(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
@@ -226,7 +229,7 @@ final class LoginManagerTest extends TestCase
self::assertSame('https://example.com/app', $response->headers->get('Location'));
}
public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void
public function test_successful_login_with_invalid_return_falls_back_to_path(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
@@ -242,7 +245,7 @@ final class LoginManagerTest extends TestCase
self::assertStringStartsWith('/login', $location);
}
public function testIpScopeDowngradesToCookieWhenIpAccessDisabled(): void
public function test_ip_scope_downgrades_to_cookie_when_ip_access_disabled(): void
{
$manager = $this->makeLoginManager(ipTtl: 0);
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip);
@@ -258,7 +261,7 @@ final class LoginManagerTest extends TestCase
self::assertTrue($response->headers->has('Set-Cookie'));
}
public function testIpScopeWhenEnabledSetsIpSession(): void
public function test_ip_scope_when_enabled_sets_ip_session(): void
{
$manager = $this->makeLoginManager(ipTtl: 1800);
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip);
@@ -274,12 +277,12 @@ final class LoginManagerTest extends TestCase
self::assertFalse($response->headers->has('Set-Cookie'));
// verify the IP session exists in the cache
$reflection = new \ReflectionProperty(LoginManager::class, 'sessionCache');
$reflection = new ReflectionProperty(LoginManager::class, 'sessionCache');
$sessionCache = $reflection->getValue($manager);
self::assertTrue($sessionCache->hasItem('ip_1.2.3.4'));
}
public function testBackupCodeAuthentication(): void
public function test_backup_code_authentication(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, token: 'backup-code-123');
@@ -294,7 +297,7 @@ final class LoginManagerTest extends TestCase
self::assertSame(303, $response->getStatusCode());
}
public function testNonceIsConsumedAfterSuccessfulLogin(): void
public function test_nonce_is_consumed_after_successful_login(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager);
@@ -307,13 +310,13 @@ final class LoginManagerTest extends TestCase
// nonce should now be marked invalid (false); look it up via the same
// cache key the manager uses (makeCacheKey rewrites '-' to '_')
$reflection = new \ReflectionProperty(LoginManager::class, 'nonceCache');
$reflection = new ReflectionProperty(LoginManager::class, 'nonceCache');
$nonceCache = $reflection->getValue($manager);
$nonceItem = $nonceCache->getItem($this->makeCacheKey('test-nonce-123'));
self::assertFalse($nonceItem->get());
}
public function testUlidCollisionThrowsHttpException(): void
public function test_ulid_collision_throws_http_exception(): void
{
// Use a stub pool where every cookie_ key is already a hit (collision)
$pool = $this->createStub(CacheItemPoolInterface::class);
@@ -322,29 +325,32 @@ final class LoginManagerTest extends TestCase
$item->method('get')->willReturn('existing');
// The nonce cache needs to work, so we return the stub item for
// cookie_ keys but a real working item for nonce keys.
$pool->method('getItem')->willReturnCallback(function (string $key) use ($item) {
$pool->method('getItem')->willReturnCallback(static function (string $key) use ($item) {
if (str_starts_with($key, 'cookie_')) {
return $item; // collision
}
// For nonce keys, return a real item from an ArrayAdapter
static $realPool = null;
$realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter();
$realPool ??= new ArrayAdapter();
return $realPool->getItem($key);
});
$pool->method('hasItem')->willReturnCallback(function (string $key) use ($item) {
$pool->method('hasItem')->willReturnCallback(static function (string $key) {
if (str_starts_with($key, 'cookie_')) {
return true;
}
static $realPool = null;
$realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter();
$realPool ??= new ArrayAdapter();
return $realPool->hasItem($key);
});
$pool->method('save')->willReturn(true);
$pool->method('saveDeferred')->willReturn(true);
$pool->method('commit')->willReturn(true);
$pool->method('getItems')->willReturnCallback(function (array $keys) {
$pool->method('getItems')->willReturnCallback(static function (array $keys) {
static $realPool = null;
$realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter();
$realPool ??= new ArrayAdapter();
return $realPool->getItems($keys);
});
$pool->method('clear')->willReturn(true);
@@ -358,7 +364,7 @@ final class LoginManagerTest extends TestCase
$manager = new LoginManager($pool, $this->backupCodeManager, $this->domainManager);
$manager->setConfig($this->makeConfig());
$manager->setLogger(new NullLogger());
$manager->setNonceCache(new \Symfony\Component\Cache\Adapter\ArrayAdapter());
$manager->setNonceCache(new ArrayAdapter());
$payload = new Payload();
$payload->id = 'collide-user';
@@ -376,7 +382,7 @@ final class LoginManagerTest extends TestCase
$manager->checkToken($payload, $request);
}
public function testCookieScopeWithCentralAuthSetsDomainOnMatchingHost(): void
public function test_cookie_scope_with_central_auth_sets_domain_on_matching_host(): void
{
$manager = $this->makeLoginManager(
subdomainRedirect: true,
@@ -400,7 +406,7 @@ final class LoginManagerTest extends TestCase
self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName());
}
public function testCookieScopeWithCentralAuthOnNonMatchingHostUsesNullDomain(): void
public function test_cookie_scope_with_central_auth_on_non_matching_host_uses_null_domain(): void
{
$manager = $this->makeLoginManager(
subdomainRedirect: true,
@@ -424,7 +430,7 @@ final class LoginManagerTest extends TestCase
self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName());
}
public function testCheckTokenWithEmptyReturnParameterFallsBackToPath(): void
public function test_check_token_with_empty_return_parameter_falls_back_to_path(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
+31 -31
View File
@@ -16,14 +16,14 @@ final class PublicPathMatcherTest extends TestCase
{
/* ── empty / disabled ──────────────────────────────────────────────── */
public function testEmptyStringResultsInNoPatterns(): void
public function test_empty_string_results_in_no_patterns(): void
{
$matcher = new PublicPathMatcher('');
self::assertTrue($matcher->isEmpty());
self::assertFalse($matcher->matches('example.com', '/public'));
}
public function testWhitespaceOnlyStringResultsInNoPatterns(): void
public function test_whitespace_only_string_results_in_no_patterns(): void
{
$matcher = new PublicPathMatcher(' ');
self::assertTrue($matcher->isEmpty());
@@ -31,20 +31,20 @@ final class PublicPathMatcherTest extends TestCase
/* ── exact path matching ───────────────────────────────────────────── */
public function testExactPathMatch(): void
public function test_exact_path_match(): void
{
$matcher = new PublicPathMatcher('/public');
self::assertTrue($matcher->matches('example.com', '/public'));
}
public function testExactPathDoesNotMatchSubpath(): void
public function test_exact_path_does_not_match_subpath(): void
{
$matcher = new PublicPathMatcher('/public');
self::assertFalse($matcher->matches('example.com', '/public/'));
self::assertFalse($matcher->matches('example.com', '/public/repo'));
}
public function testExactPathDoesNotMatchDifferentPath(): void
public function test_exact_path_does_not_match_different_path(): void
{
$matcher = new PublicPathMatcher('/public');
self::assertFalse($matcher->matches('example.com', '/private'));
@@ -53,26 +53,26 @@ final class PublicPathMatcherTest extends TestCase
/* ── single wildcard * ─────────────────────────────────────────────── */
public function testSingleWildcardMatchesOneSegment(): void
public function test_single_wildcard_matches_one_segment(): void
{
$matcher = new PublicPathMatcher('/public/*');
self::assertTrue($matcher->matches('example.com', '/public/repo'));
self::assertTrue($matcher->matches('example.com', '/public/xyz'));
}
public function testSingleWildcardDoesNotMatchBasePath(): void
public function test_single_wildcard_does_not_match_base_path(): void
{
$matcher = new PublicPathMatcher('/public/*');
self::assertFalse($matcher->matches('example.com', '/public'));
}
public function testSingleWildcardDoesNotCrossSegments(): void
public function test_single_wildcard_does_not_cross_segments(): void
{
$matcher = new PublicPathMatcher('/public/*');
self::assertFalse($matcher->matches('example.com', '/public/a/b'));
}
public function testSingleWildcardDoesNotMatchEmptySegment(): void
public function test_single_wildcard_does_not_match_empty_segment(): void
{
$matcher = new PublicPathMatcher('/public/*');
self::assertFalse($matcher->matches('example.com', '/public/'));
@@ -80,20 +80,20 @@ final class PublicPathMatcherTest extends TestCase
/* ── double wildcard ** ────────────────────────────────────────────── */
public function testDoubleWildcardMatchesMultipleSegments(): void
public function test_double_wildcard_matches_multiple_segments(): void
{
$matcher = new PublicPathMatcher('/public/**');
self::assertTrue($matcher->matches('example.com', '/public/a'));
self::assertTrue($matcher->matches('example.com', '/public/a/b/c'));
}
public function testDoubleWildcardDoesNotMatchBasePath(): void
public function test_double_wildcard_does_not_match_base_path(): void
{
$matcher = new PublicPathMatcher('/public/**');
self::assertFalse($matcher->matches('example.com', '/public'));
}
public function testDoubleWildcardMatchesTrailingSlash(): void
public function test_double_wildcard_matches_trailing_slash(): void
{
$matcher = new PublicPathMatcher('/public/**');
self::assertTrue($matcher->matches('example.com', '/public/'));
@@ -101,7 +101,7 @@ final class PublicPathMatcherTest extends TestCase
/* ── mid-path wildcards ────────────────────────────────────────────── */
public function testMidPathSingleWildcard(): void
public function test_mid_path_single_wildcard(): void
{
$matcher = new PublicPathMatcher('/api/*/status');
self::assertTrue($matcher->matches('example.com', '/api/v1/status'));
@@ -110,7 +110,7 @@ final class PublicPathMatcherTest extends TestCase
self::assertFalse($matcher->matches('example.com', '/api/status'));
}
public function testMidPathDoubleWildcard(): void
public function test_mid_path_double_wildcard(): void
{
$matcher = new PublicPathMatcher('/api/**/status');
self::assertTrue($matcher->matches('example.com', '/api/v1/status'));
@@ -120,7 +120,7 @@ final class PublicPathMatcherTest extends TestCase
/* ── multiple patterns ─────────────────────────────────────────────── */
public function testMultiplePatternsCommaSeparated(): void
public function test_multiple_patterns_comma_separated(): void
{
$matcher = new PublicPathMatcher('/public/**,/api/status,/health');
self::assertTrue($matcher->matches('example.com', '/public/repo'));
@@ -129,7 +129,7 @@ final class PublicPathMatcherTest extends TestCase
self::assertFalse($matcher->matches('example.com', '/private'));
}
public function testMultiplePatternsWithWhitespace(): void
public function test_multiple_patterns_with_whitespace(): void
{
$matcher = new PublicPathMatcher('/public/**, /api/status, /health');
self::assertTrue($matcher->matches('example.com', '/public/repo'));
@@ -137,7 +137,7 @@ final class PublicPathMatcherTest extends TestCase
self::assertTrue($matcher->matches('example.com', '/health'));
}
public function testEmptySegmentsInCommaListAreIgnored(): void
public function test_empty_segments_in_comma_list_are_ignored(): void
{
$matcher = new PublicPathMatcher('/public,,/health,');
self::assertFalse($matcher->isEmpty());
@@ -147,20 +147,20 @@ final class PublicPathMatcherTest extends TestCase
/* ── domain-prefixed patterns ──────────────────────────────────────── */
public function testDomainPrefixedPatternMatchesOnThatHost(): void
public function test_domain_prefixed_pattern_matches_on_that_host(): void
{
$matcher = new PublicPathMatcher('code.example.com/public/**');
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
}
public function testDomainPrefixedPatternDoesNotMatchOtherHost(): void
public function test_domain_prefixed_pattern_does_not_match_other_host(): void
{
$matcher = new PublicPathMatcher('code.example.com/public/**');
self::assertFalse($matcher->matches('other.example.com', '/public/repo'));
self::assertFalse($matcher->matches('example.com', '/public/repo'));
}
public function testPathWithoutDomainPrefixMatchesAnyHost(): void
public function test_path_without_domain_prefix_matches_any_host(): void
{
$matcher = new PublicPathMatcher('/public/**');
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
@@ -168,7 +168,7 @@ final class PublicPathMatcherTest extends TestCase
self::assertTrue($matcher->matches('localhost', '/public/repo'));
}
public function testMixedDomainPrefixedAndPlainPatterns(): void
public function test_mixed_domain_prefixed_and_plain_patterns(): void
{
$matcher = new PublicPathMatcher('/health,code.example.com/public/**');
self::assertTrue($matcher->matches('any.host', '/health'));
@@ -176,7 +176,7 @@ final class PublicPathMatcherTest extends TestCase
self::assertFalse($matcher->matches('other.host', '/public/repo'));
}
public function testDomainPrefixedRootPathMatchesRoot(): void
public function test_domain_prefixed_root_path_matches_root(): void
{
// host/ — the trailing slash is the entire path, nothing after it
$matcher = new PublicPathMatcher('code.example.com/');
@@ -185,7 +185,7 @@ final class PublicPathMatcherTest extends TestCase
self::assertFalse($matcher->matches('other.example.com', '/'));
}
public function testDomainPrefixedRootWithOtherPatterns(): void
public function test_domain_prefixed_root_with_other_patterns(): void
{
// The exact scenario from the bug report
$matcher = new PublicPathMatcher('code.example.com/,code.example.com/public/**');
@@ -195,7 +195,7 @@ final class PublicPathMatcherTest extends TestCase
self::assertFalse($matcher->matches('other.example.com', '/'));
}
public function testDomainPrefixIsCaseInsensitive(): void
public function test_domain_prefix_is_case_insensitive(): void
{
$matcher = new PublicPathMatcher('Code.Example.COM/public/**');
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
@@ -204,13 +204,13 @@ final class PublicPathMatcherTest extends TestCase
/* ── invalid patterns ──────────────────────────────────────────────── */
public function testPatternWithoutLeadingSlashIsIgnored(): void
public function test_pattern_without_leading_slash_is_ignored(): void
{
$matcher = new PublicPathMatcher('public');
self::assertTrue($matcher->isEmpty());
}
public function testInvalidPatternAmongValidOnesIsIgnored(): void
public function test_invalid_pattern_among_valid_ones_is_ignored(): void
{
$matcher = new PublicPathMatcher('invalid,/public');
self::assertFalse($matcher->isEmpty());
@@ -219,14 +219,14 @@ final class PublicPathMatcherTest extends TestCase
/* ── special regex characters in paths ─────────────────────────────── */
public function testSpecialRegexCharactersAreEscaped(): void
public function test_special_regex_characters_are_escaped(): void
{
$matcher = new PublicPathMatcher('/path.with.dots');
self::assertTrue($matcher->matches('example.com', '/path.with.dots'));
self::assertFalse($matcher->matches('example.com', '/pathXwithXdots'));
}
public function testPlusCharacterIsLiteral(): void
public function test_plus_character_is_literal(): void
{
$matcher = new PublicPathMatcher('/a+b');
self::assertTrue($matcher->matches('example.com', '/a+b'));
@@ -235,14 +235,14 @@ final class PublicPathMatcherTest extends TestCase
/* ── root path ─────────────────────────────────────────────────────── */
public function testRootPathMatch(): void
public function test_root_path_match(): void
{
$matcher = new PublicPathMatcher('/');
self::assertTrue($matcher->matches('example.com', '/'));
self::assertFalse($matcher->matches('example.com', '/anything'));
}
public function testWildcardAtRoot(): void
public function test_wildcard_at_root(): void
{
$matcher = new PublicPathMatcher('/*');
self::assertTrue($matcher->matches('example.com', '/anything'));
@@ -250,7 +250,7 @@ final class PublicPathMatcherTest extends TestCase
self::assertFalse($matcher->matches('example.com', '/'));
}
public function testDoubleWildcardAtRoot(): void
public function test_double_wildcard_at_root(): void
{
$matcher = new PublicPathMatcher('/**');
self::assertTrue($matcher->matches('example.com', '/'));
+3 -3
View File
@@ -11,17 +11,17 @@ final class CookieNameTraitTest extends TestCase
{
use CookieNameTrait;
public function testCookieName(): void
public function test_cookie_name(): void
{
self::assertSame('__Host-Http-Preauth', $this->cookieName());
}
public function testAuthCookieName(): void
public function test_auth_cookie_name(): void
{
self::assertSame('__Http-Domain-Preauth', $this->authCookieName());
}
public function testHeaderName(): void
public function test_header_name(): void
{
self::assertSame('X-Preauth', $this->headerName());
}
+10 -8
View File
@@ -9,7 +9,9 @@ use App\Tests\Support\TotpTestHelper;
use App\Trait\GetTotpTrait;
use OTPHP\TOTPInterface;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Throwable;
final class GetTotpTraitTest extends TestCase
{
@@ -17,7 +19,7 @@ final class GetTotpTraitTest extends TestCase
private function makeObject(): object
{
return new class () {
return new class {
use GetTotpTrait;
public function publicGetTotp(): TOTPInterface
@@ -27,18 +29,18 @@ final class GetTotpTraitTest extends TestCase
};
}
public function testSetConfigSetsProperty(): void
public function test_set_config_sets_property(): void
{
$obj = $this->makeObject();
$config = $this->makeConfig();
$obj->setConfig($config);
$reflection = new \ReflectionProperty($obj, 'config');
$reflection = new ReflectionProperty($obj, 'config');
self::assertSame($config, $reflection->getValue($obj));
}
public function testGetTotpReturnsTotpInterface(): void
public function test_get_totp_returns_totp_interface(): void
{
$obj = $this->makeObject();
$obj->setConfig($this->makeConfig());
@@ -48,7 +50,7 @@ final class GetTotpTraitTest extends TestCase
self::assertInstanceOf(TOTPInterface::class, $totp);
}
public function testGetTotpReturnsValidCode(): void
public function test_get_totp_returns_valid_code(): void
{
$obj = $this->makeObject();
$obj->setConfig($this->makeConfig());
@@ -59,7 +61,7 @@ final class GetTotpTraitTest extends TestCase
self::assertSame($this->validTotpCode(), $totp->now());
}
public function testGetTotpThrowsOnInvalidUri(): void
public function test_get_totp_throws_on_invalid_uri(): void
{
$obj = $this->makeObject();
$clock = $this->frozenClock();
@@ -83,11 +85,11 @@ final class GetTotpTraitTest extends TestCase
// Factory::loadFromProvisioningUri throws InvalidProvisioningUriException
// which is not caught by getTotp() since the instanceof check only runs
// after a successful load — so we expect a Throwable here
$this->expectException(\Throwable::class);
$this->expectException(Throwable::class);
$obj->publicGetTotp();
}
public function testGetTotpThrowsHttpExceptionWhenNotTotpType(): void
public function test_get_totp_throws_http_exception_when_not_totp_type(): void
{
// A HOTP URI loads successfully as an OTPInterface but is NOT a TOTPInterface,
// so the instanceof check in getTotp() should throw an HttpException(500)
+1 -1
View File
@@ -12,7 +12,7 @@ final class HasLoggerTraitTest extends TestCase
{
use HasLoggerTrait;
public function testSetLogger(): void
public function test_set_logger(): void
{
$logger = $this->createStub(LoggerInterface::class);
$this->setLogger($logger);
+36 -17
View File
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Tests\Unit\Trait;
use App\Trait\MakeNonceTrait;
use DateInterval;
use DateTimeInterface;
use PHPUnit\Framework\TestCase;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
@@ -20,7 +22,7 @@ final class MakeNonceTraitTest extends TestCase
{
private function makeObject(): object
{
return new class () {
return new class {
use MakeNonceTrait;
public function publicMakeNonce(int $retries = 3): string
@@ -35,7 +37,7 @@ final class MakeNonceTraitTest extends TestCase
};
}
public function testMakeNonceReturnsBase64UrlString(): void
public function test_make_nonce_returns_base64_url_string(): void
{
$obj = $this->makeObject();
$obj->setLogger(new NullLogger());
@@ -45,12 +47,12 @@ final class MakeNonceTraitTest extends TestCase
self::assertIsString($nonce);
// 15 bytes -> 20 base64 chars without padding
self::assertSame(20, strlen($nonce));
self::assertSame(20, \strlen($nonce));
// base64url charset only
self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonce);
}
public function testMakeNonceStoresNonceInCache(): void
public function test_make_nonce_stores_nonce_in_cache(): void
{
$pool = new ArrayAdapter();
$obj = $this->makeObject();
@@ -66,7 +68,7 @@ final class MakeNonceTraitTest extends TestCase
self::assertTrue($item->get());
}
public function testMakeNonceSetsExpiry(): void
public function test_make_nonce_sets_expiry(): void
{
$pool = new ArrayAdapter();
$obj = $this->makeObject();
@@ -82,7 +84,7 @@ final class MakeNonceTraitTest extends TestCase
self::assertGreaterThan(time(), (int) $expiry);
}
public function testTwoNoncesAreDifferent(): void
public function test_two_nonces_are_different(): void
{
$pool = new ArrayAdapter();
$obj = $this->makeObject();
@@ -95,7 +97,7 @@ final class MakeNonceTraitTest extends TestCase
self::assertNotSame($nonce1, $nonce2);
}
public function testMakeNonceThrowsAfterMaxRetries(): void
public function test_make_nonce_throws_after_max_retries(): void
{
// Create a stub pool that always reports every key as a hit (collision)
$pool = $this->createStub(CacheItemPoolInterface::class);
@@ -115,7 +117,7 @@ final class MakeNonceTraitTest extends TestCase
$obj->publicMakeNonce();
}
public function testMakeNonceRetriesAndSucceedsAfterCollision(): void
public function test_make_nonce_retries_and_succeeds_after_collision(): void
{
// Use a spy pool that returns isHit=true on the first getItem call
// (simulating a collision), then delegates to a real ArrayAdapter for
@@ -123,8 +125,9 @@ final class MakeNonceTraitTest extends TestCase
$realPool = new ArrayAdapter();
$collisionCount = 0;
$spyPool = new class ($realPool, $collisionCount) implements CacheItemPoolInterface {
$spyPool = new class($realPool, $collisionCount) implements CacheItemPoolInterface {
private int $hits = 0;
public function __construct(
private CacheItemPoolInterface $inner,
private int &$hitCounter,
@@ -135,69 +138,85 @@ final class MakeNonceTraitTest extends TestCase
{
$item = $this->inner->getItem($key);
// pretend the first requested key is already a hit (collision)
if ($this->hits === 0) {
$this->hits++;
$this->hitCounter++;
return new class ($key) implements CacheItemInterface {
if (0 === $this->hits) {
++$this->hits;
++$this->hitCounter;
return new class($key) implements CacheItemInterface {
public function __construct(private string $key)
{
}
public function getKey(): string
{
return $this->key;
}
public function get(): mixed
{
return true;
}
public function isHit(): bool
{
return true;
}
public function set(mixed $value): static
{
return $this;
}
public function expiresAt(?\DateTimeInterface $expiration): static
public function expiresAt(?DateTimeInterface $expiration): static
{
return $this;
}
public function expiresAfter(int|\DateInterval|null $time): static
public function expiresAfter(int|DateInterval|null $time): static
{
return $this;
}
};
}
return $item;
}
public function getItems(array $keys = []): iterable
{
return $this->inner->getItems($keys);
}
public function hasItem(string $key): bool
{
return $this->inner->hasItem($key);
}
public function clear(): bool
{
return $this->inner->clear();
}
public function deleteItem(string $key): bool
{
return $this->inner->deleteItem($key);
}
public function deleteItems(array $keys): bool
{
return $this->inner->deleteItems($keys);
}
public function save(CacheItemInterface $item): bool
{
return $this->inner->save($item);
}
public function saveDeferred(CacheItemInterface $item): bool
{
return $this->inner->saveDeferred($item);
}
public function commit(): bool
{
return $this->inner->commit();
@@ -211,11 +230,11 @@ final class MakeNonceTraitTest extends TestCase
// should retry and succeed on the second attempt
$nonce = $obj->publicMakeNonce();
self::assertIsString($nonce);
self::assertSame(20, strlen($nonce));
self::assertSame(20, \strlen($nonce));
self::assertSame(1, $collisionCount, 'Expected exactly one collision before success');
}
public function testMakeNonceThrowsImmediatelyWithZeroRetries(): void
public function test_make_nonce_throws_immediately_with_zero_retries(): void
{
$pool = $this->createStub(CacheItemPoolInterface::class);
$item = $this->createStub(CacheItemInterface::class);
+13 -13
View File
@@ -13,31 +13,31 @@ final class StringTraitTest extends TestCase
use StringTrait;
use TotpTestHelper;
public function testMakeCacheKeySanitizesInvalidChars(): void
public function test_make_cache_key_sanitizes_invalid_chars(): void
{
self::assertSame('hello_world', $this->makeCacheKey('hello world'));
self::assertSame('hello_world', $this->makeCacheKey('hello!world'));
self::assertSame('a_b_c_d', $this->makeCacheKey('a/b@c#d'));
}
public function testMakeCacheKeyPreservesValidChars(): void
public function test_make_cache_key_preserves_valid_chars(): void
{
self::assertSame('ABC_123.abc', $this->makeCacheKey('ABC_123.abc'));
}
public function testMakeCacheKeyTruncatesLongNames(): void
public function test_make_cache_key_truncates_long_names(): void
{
$long = str_repeat('a', 300);
$result = $this->makeCacheKey($long);
self::assertSame(128, mb_strlen($result));
}
public function testMakeCacheKeyEmptyString(): void
public function test_make_cache_key_empty_string(): void
{
self::assertSame('', $this->makeCacheKey(''));
}
public function testMakeCacheKeyWithOnlyInvalidChars(): void
public function test_make_cache_key_with_only_invalid_chars(): void
{
// preg_replace with + collapses consecutive invalid chars into one _
self::assertSame('_', $this->makeCacheKey('!!!'));
@@ -46,7 +46,7 @@ final class StringTraitTest extends TestCase
self::assertSame('_', $this->makeCacheKey('!@ #'));
}
public function testMakeCacheKeyTruncatesToExactly128(): void
public function test_make_cache_key_truncates_to_exactly128(): void
{
$input = str_repeat('a', 128);
self::assertSame(128, mb_strlen($this->makeCacheKey($input)));
@@ -56,7 +56,7 @@ final class StringTraitTest extends TestCase
self::assertSame(128, mb_strlen($this->makeCacheKey($input129)));
}
public function testMakeCacheKeyWithMultibyteChars(): void
public function test_make_cache_key_with_multibyte_chars(): void
{
// multibyte chars are replaced with a single underscore
$result = $this->makeCacheKey('héllo wörld');
@@ -64,7 +64,7 @@ final class StringTraitTest extends TestCase
self::assertSame('h_llo_w_rld', $result);
}
public function testMakeCacheKeyWithEmoji(): void
public function test_make_cache_key_with_emoji(): void
{
$result = $this->makeCacheKey('a🎉b');
self::assertSame('a_b', $result);
@@ -72,7 +72,7 @@ final class StringTraitTest extends TestCase
/* ── authSuccessResponse ──────────────────────────────────────────── */
public function testAuthSuccessResponseSessionMode(): void
public function test_auth_success_response_session_mode(): void
{
$config = $this->makeConfig(remoteUserMode: 'session');
$response = $this->authSuccessResponse('alice', $config);
@@ -82,7 +82,7 @@ final class StringTraitTest extends TestCase
self::assertSame('alice', $response->headers->get('Remote-User'));
}
public function testAuthSuccessResponseStaticMode(): void
public function test_auth_success_response_static_mode(): void
{
$config = $this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated');
$response = $this->authSuccessResponse('alice', $config);
@@ -91,7 +91,7 @@ final class StringTraitTest extends TestCase
self::assertSame('authenticated', $response->headers->get('Remote-User'));
}
public function testAuthSuccessResponseMappedMode(): void
public function test_auth_success_response_mapped_mode(): void
{
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin');
$response = $this->authSuccessResponse('alice', $config);
@@ -99,7 +99,7 @@ final class StringTraitTest extends TestCase
self::assertSame('admin', $response->headers->get('Remote-User'));
}
public function testAuthSuccessResponseMappedModeFallback(): void
public function test_auth_success_response_mapped_mode_fallback(): void
{
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin');
$response = $this->authSuccessResponse('unknown', $config);
@@ -107,7 +107,7 @@ final class StringTraitTest extends TestCase
self::assertSame('unknown', $response->headers->get('Remote-User'));
}
public function testAuthSuccessResponseNoneModeOmitsHeader(): void
public function test_auth_success_response_none_mode_omits_header(): void
{
$config = $this->makeConfig(remoteUserMode: 'none');
$response = $this->authSuccessResponse('alice', $config);
+7 -5
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Unit;
use App\Utilities;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use Psr\Clock\ClockInterface;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
@@ -15,10 +16,11 @@ final class UtilitiesTest extends TestCase
{
$pool ??= new ArrayAdapter();
$clock ??= $this->createStub(ClockInterface::class);
return new Utilities($clock, $pool);
}
public function testLoadTotpReturnsCachedValueWhenPresent(): void
public function test_load_totp_returns_cached_value_when_present(): void
{
$pool = new ArrayAdapter();
$item = $pool->getItem('totp');
@@ -32,7 +34,7 @@ final class UtilitiesTest extends TestCase
self::assertSame('otpauth://totp/cached?secret=ABCDEFGH', $result);
}
public function testLoadTotpGeneratesAndStoresWhenMissing(): void
public function test_load_totp_generates_and_stores_when_missing(): void
{
$pool = new ArrayAdapter();
$utilities = $this->makeUtilities($pool);
@@ -48,7 +50,7 @@ final class UtilitiesTest extends TestCase
self::assertSame($result, $cached->get());
}
public function testLoadTotpSetsFarFutureExpiry(): void
public function test_load_totp_sets_far_future_expiry(): void
{
$pool = new ArrayAdapter();
$utilities = $this->makeUtilities($pool);
@@ -58,10 +60,10 @@ final class UtilitiesTest extends TestCase
$cached = $pool->getItem('totp');
$expiry = $cached->getMetadata()['expiry'];
// 2999-12-31 is well in the future, far beyond any reasonable test timestamp
self::assertGreaterThan((new \DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry);
self::assertGreaterThan((new DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry);
}
public function testLoadTotpIsIdempotentAfterGeneration(): void
public function test_load_totp_is_idempotent_after_generation(): void
{
$pool = new ArrayAdapter();
$utilities = $this->makeUtilities($pool);
+2
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/vendor/autoload.php';