diff --git a/.ci/conformance.sh b/.ci/conformance.sh new file mode 100755 index 0000000..526c709 --- /dev/null +++ b/.ci/conformance.sh @@ -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 +# 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
+# +# 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 +has_file() { [ -f "$1" ]; } +# not_has_file +not_has_file() { [ ! -f "$1" ]; } +# file_contains (silently false if path missing) +file_contains() { [ -f "$1" ] && grep -qE "$2" "$1"; } +# any_file_contains +# +# 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 — 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 diff --git a/.ci/css-control-size.py b/.ci/css-control-size.py new file mode 100755 index 0000000..8b9a579 --- /dev/null +++ b/.ci/css-control-size.py @@ -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 [file...] +Output: one line per violation: ":\t\t\t" +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[0-9]*\.?[0-9]+)\s*(?Ppx|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[0-9]*\.?[0-9]+)\s*(?Ppx|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()) diff --git a/.editorconfig b/.editorconfig index 733c9cf..3f23196 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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 diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index b840706..7041d1f 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -1,18 +1,73 @@ 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) + ); diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..235a3c1 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b56569a --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/composer.json b/composer.json index 9b52f81..b658372 100644 --- a/composer.json +++ b/composer.json @@ -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.*" diff --git a/composer.lock b/composer.lock index c8afc97..f70b185 100644 --- a/composer.lock +++ b/composer.lock @@ -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" } diff --git a/config/bundles.php b/config/bundles.php index bdd37ac..389e897 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -1,5 +1,7 @@ ['all' => true], Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], diff --git a/config/preload.php b/config/preload.php index 726f5d4..55cca5f 100644 --- a/config/preload.php +++ b/config/preload.php @@ -1,8 +1,10 @@ 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 diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..232d7e3 --- /dev/null +++ b/phpstan.neon.dist @@ -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 +# ───────────────────────────────────────────────────────────────────────────── diff --git a/public/index.php b/public/index.php index 97e228d..508a169 100644 --- a/public/index.php +++ b/public/index.php @@ -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']); }; diff --git a/readme.md b/readme.md index b3606cb..cb12330 100644 --- a/readme.md +++ b/readme.md @@ -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 diff --git a/src/Command/GenerateBackupCodesCommand.php b/src/Command/GenerateBackupCodesCommand.php index 8330d1a..edfe35c 100644 --- a/src/Command/GenerateBackupCodesCommand.php +++ b/src/Command/GenerateBackupCodesCommand.php @@ -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; } } diff --git a/src/ConfigBag.php b/src/ConfigBag.php index 4b5c227..e3229d4 100644 --- a/src/ConfigBag.php +++ b/src/ConfigBag.php @@ -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; } diff --git a/src/Data/Payload.php b/src/Data/Payload.php index 6cc60aa..2ced1f8 100644 --- a/src/Data/Payload.php +++ b/src/Data/Payload.php @@ -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; } diff --git a/src/Enum/Scope.php b/src/Enum/Scope.php index d4d0502..8d463ae 100644 --- a/src/Enum/Scope.php +++ b/src/Enum/Scope.php @@ -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'; } diff --git a/src/Listener/AcceptListener.php b/src/Listener/AcceptListener.php index a1350ea..c0a590a 100644 --- a/src/Listener/AcceptListener.php +++ b/src/Listener/AcceptListener.php @@ -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; } diff --git a/src/Listener/AllowListener.php b/src/Listener/AllowListener.php index 120aa96..9918970 100644 --- a/src/Listener/AllowListener.php +++ b/src/Listener/AllowListener.php @@ -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; } diff --git a/src/Listener/InterceptListener.php b/src/Listener/InterceptListener.php index d07d0d7..22d23d8 100644 --- a/src/Listener/InterceptListener.php +++ b/src/Listener/InterceptListener.php @@ -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, ); } diff --git a/src/Listener/LoginListener.php b/src/Listener/LoginListener.php index e2157b5..1d2a561 100644 --- a/src/Listener/LoginListener.php +++ b/src/Listener/LoginListener.php @@ -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]); } } diff --git a/src/Listener/PublicAccessListener.php b/src/Listener/PublicAccessListener.php index adbb209..bbec36d 100644 --- a/src/Listener/PublicAccessListener.php +++ b/src/Listener/PublicAccessListener.php @@ -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, ], )); } diff --git a/src/Listener/RejectListener.php b/src/Listener/RejectListener.php index fb7b704..4f4d8f5 100644 --- a/src/Listener/RejectListener.php +++ b/src/Listener/RejectListener.php @@ -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'], )); } } diff --git a/src/Listener/SecurityHeadersListener.php b/src/Listener/SecurityHeadersListener.php index 33aa058..4f4d324 100644 --- a/src/Listener/SecurityHeadersListener.php +++ b/src/Listener/SecurityHeadersListener.php @@ -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'); diff --git a/src/MonitorCacheKeys.php b/src/MonitorCacheKeys.php index 8497428..d809685 100644 --- a/src/MonitorCacheKeys.php +++ b/src/MonitorCacheKeys.php @@ -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(); diff --git a/src/PersistCache.php b/src/PersistCache.php index d1fcfa4..1d42d3c 100644 --- a/src/PersistCache.php +++ b/src/PersistCache.php @@ -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); } diff --git a/src/Service/BackupCodeInterface.php b/src/Service/BackupCodeInterface.php index b18dbe7..557a340 100644 --- a/src/Service/BackupCodeInterface.php +++ b/src/Service/BackupCodeInterface.php @@ -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; } diff --git a/src/Service/BackupCodeManager.php b/src/Service/BackupCodeManager.php index 2858d6d..6129407 100644 --- a/src/Service/BackupCodeManager.php +++ b/src/Service/BackupCodeManager.php @@ -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); } diff --git a/src/Service/DomainInterface.php b/src/Service/DomainInterface.php index 6f73b46..7b65706 100644 --- a/src/Service/DomainInterface.php +++ b/src/Service/DomainInterface.php @@ -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; } diff --git a/src/Service/DomainManager.php b/src/Service/DomainManager.php index 4bc3ae2..21f8d3a 100644 --- a/src/Service/DomainManager.php +++ b/src/Service/DomainManager.php @@ -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; } } diff --git a/src/Service/LoginManager.php b/src/Service/LoginManager.php index 3e1f961..a5c6f10 100644 --- a/src/Service/LoginManager.php +++ b/src/Service/LoginManager.php @@ -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); diff --git a/src/Service/PublicPathMatcher.php b/src/Service/PublicPathMatcher.php index 0951f35..75a616e 100644 --- a/src/Service/PublicPathMatcher.php +++ b/src/Service/PublicPathMatcher.php @@ -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.'$#'; } } diff --git a/src/Trait/GetTotpTrait.php b/src/Trait/GetTotpTrait.php index d6a01f9..2f9d710 100644 --- a/src/Trait/GetTotpTrait.php +++ b/src/Trait/GetTotpTrait.php @@ -25,7 +25,7 @@ trait GetTotpTrait { $otp = Factory::loadFromProvisioningUri( $this->config->totpUri(), - $this->config->clock() + $this->config->clock(), ); if ($otp instanceof TOTPInterface) { return $otp; diff --git a/src/Trait/MakeNonceTrait.php b/src/Trait/MakeNonceTrait.php index c472e8b..ae52f7e 100644 --- a/src/Trait/MakeNonceTrait.php +++ b/src/Trait/MakeNonceTrait.php @@ -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; } } diff --git a/src/Trait/StringTrait.php b/src/Trait/StringTrait.php index 0acf39b..3e703e6 100644 --- a/src/Trait/StringTrait.php +++ b/src/Trait/StringTrait.php @@ -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, }; } } diff --git a/src/Utilities.php b/src/Utilities.php index 1bbd503..1f35e93 100644 --- a/src/Utilities.php +++ b/src/Utilities.php @@ -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, ); } } diff --git a/symfony.lock b/symfony.lock index a53ef73..03f8191 100644 --- a/symfony.lock +++ b/symfony.lock @@ -11,6 +11,9 @@ ".php-cs-fixer.dist.php" ] }, + "phpstan/phpstan": { + "version": "2.2.15" + }, "phpunit/phpunit": { "version": "13.2", "recipe": { diff --git a/templates/base.html.twig b/templates/base.html.twig index e80ab9e..524fb50 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -3,7 +3,7 @@ {{ env.title }} - + {{- include('_style.html.twig') -}} diff --git a/tests/Functional/AuthenticationFlowTest.php b/tests/Functional/AuthenticationFlowTest.php index 82a1c7d..f8372f1 100644 --- a/tests/Functional/AuthenticationFlowTest.php +++ b/tests/Functional/AuthenticationFlowTest.php @@ -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(); diff --git a/tests/Functional/CacheControlFlowTest.php b/tests/Functional/CacheControlFlowTest.php index ceeb507..21d46ca 100644 --- a/tests/Functional/CacheControlFlowTest.php +++ b/tests/Functional/CacheControlFlowTest.php @@ -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'); diff --git a/tests/Functional/PublicAccessFlowTest.php b/tests/Functional/PublicAccessFlowTest.php index 41666f1..da08864 100644 --- a/tests/Functional/PublicAccessFlowTest.php +++ b/tests/Functional/PublicAccessFlowTest.php @@ -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'); diff --git a/tests/Support/ListenerTestHelper.php b/tests/Support/ListenerTestHelper.php index e312c93..8ba3065 100644 --- a/tests/Support/ListenerTestHelper.php +++ b/tests/Support/ListenerTestHelper.php @@ -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; diff --git a/tests/Support/TotpTestHelper.php b/tests/Support/TotpTestHelper.php index 1ac63be..569bf8a 100644 --- a/tests/Support/TotpTestHelper.php +++ b/tests/Support/TotpTestHelper.php @@ -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); } } diff --git a/tests/TestKernel.php b/tests/TestKernel.php index 61f6d53..670dbea 100644 --- a/tests/TestKernel.php +++ b/tests/TestKernel.php @@ -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) { diff --git a/tests/Unit/ClockTest.php b/tests/Unit/ClockTest.php index 3a921ca..116ccad 100644 --- a/tests/Unit/ClockTest.php +++ b/tests/Unit/ClockTest.php @@ -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()); } diff --git a/tests/Unit/Command/GenerateBackupCodesCommandTest.php b/tests/Unit/Command/GenerateBackupCodesCommandTest.php index 0eea2a1..48ed80e 100644 --- a/tests/Unit/Command/GenerateBackupCodesCommandTest.php +++ b/tests/Unit/Command/GenerateBackupCodesCommandTest.php @@ -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(); diff --git a/tests/Unit/ConfigBagRemoteUserTest.php b/tests/Unit/ConfigBagRemoteUserTest.php index a46bf97..22eaa6b 100644 --- a/tests/Unit/ConfigBagRemoteUserTest.php +++ b/tests/Unit/ConfigBagRemoteUserTest.php @@ -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', diff --git a/tests/Unit/ConfigBagTest.php b/tests/Unit/ConfigBagTest.php index bd72c52..321cb54 100644 --- a/tests/Unit/ConfigBagTest.php +++ b/tests/Unit/ConfigBagTest.php @@ -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(); diff --git a/tests/Unit/Data/PayloadTest.php b/tests/Unit/Data/PayloadTest.php index d9fc4e0..fad1a58 100644 --- a/tests/Unit/Data/PayloadTest.php +++ b/tests/Unit/Data/PayloadTest.php @@ -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'; diff --git a/tests/Unit/Enum/ScopeTest.php b/tests/Unit/Enum/ScopeTest.php index 2ecd532..5de69a7 100644 --- a/tests/Unit/Enum/ScopeTest.php +++ b/tests/Unit/Enum/ScopeTest.php @@ -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('')); diff --git a/tests/Unit/Listener/AcceptListenerTest.php b/tests/Unit/Listener/AcceptListenerTest.php index 530e55f..2ac0c20 100644 --- a/tests/Unit/Listener/AcceptListenerTest.php +++ b/tests/Unit/Listener/AcceptListenerTest.php @@ -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); diff --git a/tests/Unit/Listener/AllowListenerTest.php b/tests/Unit/Listener/AllowListenerTest.php index 5641447..a27ef36 100644 --- a/tests/Unit/Listener/AllowListenerTest.php +++ b/tests/Unit/Listener/AllowListenerTest.php @@ -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); diff --git a/tests/Unit/Listener/InterceptListenerTest.php b/tests/Unit/Listener/InterceptListenerTest.php index d12e11f..e5d6631 100644 --- a/tests/Unit/Listener/InterceptListenerTest.php +++ b/tests/Unit/Listener/InterceptListenerTest.php @@ -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; } } diff --git a/tests/Unit/Listener/LoginListenerTest.php b/tests/Unit/Listener/LoginListenerTest.php index 9ca06eb..0871de2 100644 --- a/tests/Unit/Listener/LoginListenerTest.php +++ b/tests/Unit/Listener/LoginListenerTest.php @@ -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('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'); diff --git a/tests/Unit/Listener/PublicAccessListenerTest.php b/tests/Unit/Listener/PublicAccessListenerTest.php index 7261060..cfb5e7f 100644 --- a/tests/Unit/Listener/PublicAccessListenerTest.php +++ b/tests/Unit/Listener/PublicAccessListenerTest.php @@ -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); diff --git a/tests/Unit/Listener/RejectListenerTest.php b/tests/Unit/Listener/RejectListenerTest.php index d3307f8..8fb675c 100644 --- a/tests/Unit/Listener/RejectListenerTest.php +++ b/tests/Unit/Listener/RejectListenerTest.php @@ -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); diff --git a/tests/Unit/Listener/SecurityHeadersListenerTest.php b/tests/Unit/Listener/SecurityHeadersListenerTest.php index 1175b5e..c5d40e4 100644 --- a/tests/Unit/Listener/SecurityHeadersListenerTest.php +++ b/tests/Unit/Listener/SecurityHeadersListenerTest.php @@ -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('
login
', 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('

teapot

', 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('
login
', 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'); diff --git a/tests/Unit/MonitorCacheKeysTest.php b/tests/Unit/MonitorCacheKeysTest.php index ba8400f..64297fe 100644 --- a/tests/Unit/MonitorCacheKeysTest.php +++ b/tests/Unit/MonitorCacheKeysTest.php @@ -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) { diff --git a/tests/Unit/PersistCacheTest.php b/tests/Unit/PersistCacheTest.php index e5421b1..c47448a 100644 --- a/tests/Unit/PersistCacheTest.php +++ b/tests/Unit/PersistCacheTest.php @@ -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(); diff --git a/tests/Unit/Service/BackupCodeManagerTest.php b/tests/Unit/Service/BackupCodeManagerTest.php index e6905f7..dc7b1c2 100644 --- a/tests/Unit/Service/BackupCodeManagerTest.php +++ b/tests/Unit/Service/BackupCodeManagerTest.php @@ -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])); } } diff --git a/tests/Unit/Service/DomainManagerTest.php b/tests/Unit/Service/DomainManagerTest.php index 27403ea..9b6afa5 100644 --- a/tests/Unit/Service/DomainManagerTest.php +++ b/tests/Unit/Service/DomainManagerTest.php @@ -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')); diff --git a/tests/Unit/Service/LoginManagerTest.php b/tests/Unit/Service/LoginManagerTest.php index 4180947..d0dbe8c 100644 --- a/tests/Unit/Service/LoginManagerTest.php +++ b/tests/Unit/Service/LoginManagerTest.php @@ -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); diff --git a/tests/Unit/Service/PublicPathMatcherTest.php b/tests/Unit/Service/PublicPathMatcherTest.php index ae96b71..6f9a4fd 100644 --- a/tests/Unit/Service/PublicPathMatcherTest.php +++ b/tests/Unit/Service/PublicPathMatcherTest.php @@ -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', '/')); diff --git a/tests/Unit/Trait/CookieNameTraitTest.php b/tests/Unit/Trait/CookieNameTraitTest.php index 648d53a..dae0318 100644 --- a/tests/Unit/Trait/CookieNameTraitTest.php +++ b/tests/Unit/Trait/CookieNameTraitTest.php @@ -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()); } diff --git a/tests/Unit/Trait/GetTotpTraitTest.php b/tests/Unit/Trait/GetTotpTraitTest.php index cfd7c8f..9b043f5 100644 --- a/tests/Unit/Trait/GetTotpTraitTest.php +++ b/tests/Unit/Trait/GetTotpTraitTest.php @@ -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) diff --git a/tests/Unit/Trait/HasLoggerTraitTest.php b/tests/Unit/Trait/HasLoggerTraitTest.php index 560890a..d5b0cca 100644 --- a/tests/Unit/Trait/HasLoggerTraitTest.php +++ b/tests/Unit/Trait/HasLoggerTraitTest.php @@ -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); diff --git a/tests/Unit/Trait/MakeNonceTraitTest.php b/tests/Unit/Trait/MakeNonceTraitTest.php index b998d2b..01ee54a 100644 --- a/tests/Unit/Trait/MakeNonceTraitTest.php +++ b/tests/Unit/Trait/MakeNonceTraitTest.php @@ -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); diff --git a/tests/Unit/Trait/StringTraitTest.php b/tests/Unit/Trait/StringTraitTest.php index 7c6a7c8..9487380 100644 --- a/tests/Unit/Trait/StringTraitTest.php +++ b/tests/Unit/Trait/StringTraitTest.php @@ -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); diff --git a/tests/Unit/UtilitiesTest.php b/tests/Unit/UtilitiesTest.php index 89e13b3..54e103d 100644 --- a/tests/Unit/UtilitiesTest.php +++ b/tests/Unit/UtilitiesTest.php @@ -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); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e16021e..574b0af 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,5 +1,7 @@