diff --git a/assets/css/magicauth-admin.css b/assets/css/magicauth-admin.css index 62df64f..f82dd81 100644 --- a/assets/css/magicauth-admin.css +++ b/assets/css/magicauth-admin.css @@ -375,6 +375,44 @@ body.admin-bar .magicauth-admin .magicauth-topbar__bar { top: 32px; } line-height: 1.55; } +/*
/ disclosure widget. is keyboard-focusable + natively; no JS. Native marker hidden so we can render our own chevron. */ +.magicauth-admin details.magicauth-block > summary { + list-style: none; + cursor: pointer; + user-select: none; + position: relative; + padding-right: 32px; +} + +.magicauth-admin details.magicauth-block > summary::-webkit-details-marker { display: none; } + +.magicauth-admin details.magicauth-block > summary::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + right: 8px; + margin: auto 0; + width: 9px; + height: 9px; + border-right: 2px solid var(--tx-muted); + border-bottom: 2px solid var(--tx-muted); + transform: rotate(45deg); + transform-origin: center; + transition: transform 0.15s ease; +} + +.magicauth-admin details.magicauth-block[open] > summary::after { + transform: rotate(-135deg); +} + +.magicauth-admin details.magicauth-block > summary:focus-visible { + outline: 2px solid var(--ettic-accent, #2271b1); + outline-offset: 4px; + border-radius: 4px; +} + /* Cards. Namespaced to avoid colliding with WP core `.card` (caps at 520px). */ .magicauth-admin .magicauth-card { background: var(--ettic-surface); @@ -833,7 +871,7 @@ body.admin-bar .magicauth-admin .magicauth-topbar__bar { top: 32px; } align-items: center; justify-content: space-between; gap: 16px; - padding: 14px 8px; + padding: 14px; position: relative; } diff --git a/assets/css/magicauth.css b/assets/css/magicauth.css index d3f208c..a93abd0 100644 --- a/assets/css/magicauth.css +++ b/assets/css/magicauth.css @@ -13,6 +13,7 @@ --magicauth-color-divider: #e9ecef; --magicauth-color-error: #b32d2e; --magicauth-color-focus-ring: var(--magicauth-color-primary); + --magicauth-color-link: var(--magicauth-color-primary); --magicauth-card-max-width: 480px; --magicauth-card-padding: 56px 48px; --magicauth-card-padding-mobile: 32px 24px; @@ -325,7 +326,7 @@ } .magicauth-link { - color: var(--magicauth-color-primary); + color: var(--magicauth-color-link, var(--magicauth-color-primary)); text-decoration: underline; text-underline-offset: 2px; } @@ -446,3 +447,45 @@ outline: 2px solid CanvasText; } } + +/* Dark mode — explicit. Body-class specificity (0,1,1) beats :root (0,0,1), + so the shell-vars block can still set per-instance brand_color / link_color / + font_stack without being overridden. Curated values, not exposed to admin + pickers. + !!! SYNC-LOCK !!! --magicauth-color-surface (#181c22) MUST equal the PHP + constant MagicAuth\Admin\Settings::DARK_SURFACE_HEX + (includes/Admin/Settings.php:~42). The PHP constant feeds the brand-color + contrast check; the CSS variable paints the card. If either changes without + the other, dark-mode contrast warnings will lie. Value also duplicated at + line ~482 below (auto-mode @media block) — update all three together. */ +body.magicauth-mode-dark { + --magicauth-color-page: #0e1116; + --magicauth-color-surface: #181c22; /* SYNC with Settings::DARK_SURFACE_HEX */ + --magicauth-color-code-bg: #202832; + --magicauth-color-text: #eef0f3; + --magicauth-color-text-muted: #9aa3ad; + --magicauth-color-border: #2a313a; + --magicauth-color-border-strong: #3a424c; + --magicauth-color-divider: #262d36; + --magicauth-color-error: #ff6b6b; +} + +/* Dark mode — auto. Only kicks in when admin chose color_mode = auto AND + the visitor's browser reports a dark preference. + !!! SYNC-LOCK !!! --magicauth-color-surface (#181c22) MUST equal the PHP + constant MagicAuth\Admin\Settings::DARK_SURFACE_HEX + (includes/Admin/Settings.php:~42). See full note above the explicit-dark + block (~line 451). Three locations to update in lockstep. */ +@media (prefers-color-scheme: dark) { + body.magicauth-mode-auto { + --magicauth-color-page: #0e1116; + --magicauth-color-surface: #181c22; /* SYNC with Settings::DARK_SURFACE_HEX */ + --magicauth-color-code-bg: #202832; + --magicauth-color-text: #eef0f3; + --magicauth-color-text-muted: #9aa3ad; + --magicauth-color-border: #2a313a; + --magicauth-color-border-strong: #3a424c; + --magicauth-color-divider: #262d36; + --magicauth-color-error: #ff6b6b; + } +} diff --git a/assets/js/magicauth-admin.js b/assets/js/magicauth-admin.js index 6424c47..497b670 100644 --- a/assets/js/magicauth-admin.js +++ b/assets/js/magicauth-admin.js @@ -12,6 +12,7 @@ function init() { initMediaPickers(); initColorPickers(); + initColorFollowers(); initRowDirtyMarks(); initDirtyTracking(); initCharCounters(); @@ -107,6 +108,54 @@ } ); } + // Follower color pickers: when the text input is blank, the swatch mirrors + // a source picker (e.g. link_color follows brand_color). Typing a hex or + // picking a swatch color writes to the text input, which disconnects. + function initColorFollowers() { + var HEX_RE = /^#?[0-9a-fA-F]{6}$/; + var normalize = function ( v ) { return v.charAt( 0 ) === '#' ? v : '#' + v; }; + + document.querySelectorAll( '.magicauth-admin .magicauth-color[data-magicauth-color-follows]' ).forEach( function ( follower ) { + var sourceKey = follower.getAttribute( 'data-magicauth-color-follows' ); + if ( ! sourceKey ) { + return; + } + var sourceText = document.querySelector( 'input[name="magicauth_settings[' + sourceKey + ']"]' ); + var swatch = follower.querySelector( 'input[type="color"]' ); + var text = follower.querySelector( 'input[type="text"]' ); + if ( ! sourceText || ! swatch || ! text ) { + return; + } + + function isConnected() { return '' === text.value.trim(); } + function mirrorFromSource() { + var v = sourceText.value.trim(); + if ( HEX_RE.test( v ) ) { + swatch.value = normalize( v ); + } + } + + // Cleared text → snap swatch back to source. + text.addEventListener( 'input', function () { + if ( isConnected() ) { + mirrorFromSource(); + } + } ); + + // Source moved → mirror only while connected. + sourceText.addEventListener( 'input', function () { + if ( isConnected() ) { + mirrorFromSource(); + } + } ); + + // Initial render: if follower is blank, mirror immediately. + if ( isConnected() ) { + mirrorFromSource(); + } + } ); + } + // Per-row dirty mark — auto-injected, opacity toggled via .is-dirty function initRowDirtyMarks() { document.querySelectorAll( '.magicauth-admin .magicauth-row' ).forEach( function ( row ) { diff --git a/includes/Admin/Settings.php b/includes/Admin/Settings.php index 8a8d95a..b81960a 100644 --- a/includes/Admin/Settings.php +++ b/includes/Admin/Settings.php @@ -28,6 +28,19 @@ final class Settings { private const MAX_LINK_USES_PRESETS = [ 1, 2, 3, 5, 10 ]; + private const FONT_STACK_KEYS = [ 'system', 'sans-modern', 'serif', 'mono', 'rounded' ]; + + private const COLOR_MODE_KEYS = [ 'light', 'dark', 'auto' ]; + + // Curated dark-mode surface — used by the brand-color contrast check below. + // !!! SYNC-LOCK !!! This value MUST equal the --magicauth-color-surface + // declarations in assets/css/magicauth.css at: + // - line ~463 (body.magicauth-mode-dark block) + // - line ~482 (@media (prefers-color-scheme: dark) > body.magicauth-mode-auto) + // Three locations, one truth. If you change one, change all three or the + // contrast warning shown to admins will disagree with the rendered card. + private const DARK_SURFACE_HEX = '#181c22'; + // Keyed by extension regex (wp_check_filetype_and_ext format); membership checks hit values. private const LOGO_MIMES = [ 'png' => 'image/png', @@ -36,6 +49,18 @@ final class Settings { 'svg' => 'image/svg+xml', ]; + // Background images: raster only. SVG explicitly excluded — larger attack + // surface than logos and no rendering benefit at full-page scale. + private const BACKGROUND_MIMES = [ + 'png' => 'image/png', + 'jpg|jpeg' => 'image/jpeg', + 'webp' => 'image/webp', + ]; + + // Background image soft-limit. Files larger than this trigger a non-blocking + // "consider compressing" admin notice but still save successfully. + private const BACKGROUND_SIZE_SOFT_LIMIT_BYTES = 1024 * 1024; // 1 MB. + public static function setup(): void { add_action( 'admin_init', [ self::class, 'register' ] ); add_action( 'admin_menu', [ self::class, 'menu' ] ); @@ -161,6 +186,7 @@ public static function render_page(): void {
+ @@ -173,66 +199,86 @@ public static function render_page(): void { private static function render_section_general(): void { ?> -
-
+
+

-
+
- +
-
-
+
+

-
+
+
-
+ + +
+ +

+

+
+
+ + + + + + +
+
-
-
+
+

-
+
-
+ -
-
+
+

-
+
-
+ -
-
+
+

-
+
@@ -281,7 +327,7 @@ private static function render_section_diagnostics(): void {
-
+ self::BACKGROUND_SIZE_SOFT_LIMIT_BYTES ) { + add_settings_error( + self::OPTION_NAME, + 'magicauth_bg_large', + sprintf( + /* translators: %s: file size in MB */ + __( 'Background image saved. File is %s MB — consider compressing for faster page load.', 'magicauth' ), + number_format( $size / self::BACKGROUND_SIZE_SOFT_LIMIT_BYTES, 1 ) + ), + 'warning' + ); + } + + return $attachment_id; + } + public static function field_ttl_minutes(): void { $value = (int) magicauth_get_setting( 'ttl_minutes', 10 ); $name = sprintf( '%s[ttl_minutes]', self::OPTION_NAME ); @@ -495,32 +719,17 @@ public static function field_max_link_uses(): void { } public static function field_redirect_to_default(): void { - $value = (string) magicauth_get_setting( 'redirect_to_default', 'auto' ); - $name = sprintf( '%s[redirect_to_default]', self::OPTION_NAME ); - $options = [ - 'auto' => __( 'Auto (admin or home, by capability)', 'magicauth' ), - 'home' => __( 'Site home', 'magicauth' ), - 'admin' => __( 'Admin dashboard', 'magicauth' ), - ]; - ?> -
-
- -

-
-
-
- -
-
-
- __( 'Auto (admin or home, by capability)', 'magicauth' ), + 'home' => __( 'Site home', 'magicauth' ), + 'admin' => __( 'Admin dashboard', 'magicauth' ), + ] + ); } public static function field_replace_default( bool $weak_salts = false ): void { @@ -596,6 +805,119 @@ public static function field_brand_color(): void { +
+
+ +

+
+
+
+ + +
+
+
+ +
+
+ +

+
+
+ + +
+
+ +
+
+ +

+
+
+ + +
+
+ __( 'System default', 'magicauth' ), + 'sans-modern' => __( 'Modern sans (Inter)', 'magicauth' ), + 'serif' => __( 'Serif (Georgia)', 'magicauth' ), + 'mono' => __( 'Monospace', 'magicauth' ), + 'rounded' => __( 'Rounded', 'magicauth' ), + ] + ); + } + + public static function field_link_color(): void { + $value = (string) magicauth_get_setting( 'link_color', '' ); + $brand_raw = (string) magicauth_get_setting( 'brand_color', '#2271b1' ); + $brand_default = (string) ( sanitize_hex_color( $brand_raw ) ?: '#2271b1' ); + $name = sprintf( '%s[link_color]', self::OPTION_NAME ); + ?> +
+
+ +

+
+
+
+ + +
+
+
+ __( 'Light', 'magicauth' ), + 'dark' => __( 'Dark', 'magicauth' ), + 'auto' => __( "Auto (follow visitor's browser preference)", 'magicauth' ), + ] + ); + } + + public static function field_background_image(): void { + self::render_media_picker_field( + 'background_attachment_id', + (int) magicauth_get_setting( 'background_attachment_id', 0 ), + __( 'Page background image', 'magicauth' ), + __( 'Optional. PNG, JPG, or WebP. Rendered as a cover-fitted, centered background behind the sign-in card. Page background color shows through transparent areas or if the image fails to load.', 'magicauth' ) + ); + } + public static function field_agency_credit_name(): void { $value = (string) magicauth_get_setting( 'agency_credit_name', '' ); $name = sprintf( '%s[agency_credit_name]', self::OPTION_NAME ); @@ -707,6 +1029,35 @@ public static function field_throttle(): void { field markup. Same row scaffold as the v1.0 select fields; + * keeps three field_* methods at ~4 LOC each instead of 25. + * + * @param array $options Map of stored value => translated label. + */ + private static function render_select_field( string $option_key, string $value, string $label, string $help, array $options ): void { + $name = sprintf( '%s[%s]', self::OPTION_NAME, $option_key ); + ?> +
+
+ +

+
+
+
+ +
+
+
+ '', 'logo_attachment_id' => 0, 'brand_color' => '#2271b1', + 'link_color' => '', + 'page_color' => '#eeeeee', + 'background_attachment_id' => 0, + 'card_radius' => 6, + 'card_width' => 480, + 'font_stack' => 'system', + 'color_mode' => 'light', 'agency_credit_name' => '', 'agency_credit_url' => '', 'agency_credit_icon_id' => 0, diff --git a/includes/helpers.php b/includes/helpers.php index e328bbc..cf9d57f 100644 --- a/includes/helpers.php +++ b/includes/helpers.php @@ -10,8 +10,17 @@ defined( 'ABSPATH' ) || exit; if ( ! function_exists( 'magicauth_get_settings' ) ) { - /** Settings merged onto defaults. */ + /** + * Settings merged onto defaults. Memoized per-request via $GLOBALS so the + * 7 calls on the login render path don't each rebuild the defaults array + * and run array_replace_recursive. Invalidated by the option-update hooks + * registered at the bottom of this file (and by the test stubs). + */ function magicauth_get_settings(): array { + if ( isset( $GLOBALS['magicauth_settings_cache'] ) && is_array( $GLOBALS['magicauth_settings_cache'] ) ) { + return $GLOBALS['magicauth_settings_cache']; + } + $defaults = [ 'ttl_minutes' => 10, 'max_link_uses' => 2, @@ -30,6 +39,13 @@ function magicauth_get_settings(): array { 'company_name' => '', 'logo_attachment_id' => 0, 'brand_color' => '#2271b1', + 'link_color' => '', + 'page_color' => '#eeeeee', + 'background_attachment_id' => 0, + 'card_radius' => 6, + 'card_width' => 480, + 'font_stack' => 'system', + 'color_mode' => 'light', 'agency_credit_name' => '', 'agency_credit_url' => '', 'agency_credit_icon_id' => 0, @@ -44,8 +60,23 @@ function magicauth_get_settings(): array { $saved = []; } - return array_replace_recursive( $defaults, $saved ); + $GLOBALS['magicauth_settings_cache'] = array_replace_recursive( $defaults, $saved ); + return $GLOBALS['magicauth_settings_cache']; + } +} + +if ( ! function_exists( 'magicauth_invalidate_settings_cache' ) ) { + /** Drop the per-request settings memo. Tests reset state; admin saves fire this. */ + function magicauth_invalidate_settings_cache(): void { + unset( $GLOBALS['magicauth_settings_cache'] ); + } +} + +if ( function_exists( 'add_action' ) ) { + foreach ( [ 'update_option_magicauth_settings', 'add_option_magicauth_settings', 'delete_option_magicauth_settings' ] as $magicauth_cache_hook ) { + add_action( $magicauth_cache_hook, 'magicauth_invalidate_settings_cache' ); } + unset( $magicauth_cache_hook ); } if ( ! function_exists( 'magicauth_get_setting' ) ) { @@ -146,24 +177,101 @@ function magicauth_hash_email( string $email ): string { } } -if ( ! function_exists( 'magicauth_yiq_text_color' ) ) { - /** Black or white text for a hex bg via YIQ luminance. */ - function magicauth_yiq_text_color( string $hex ): string { +if ( ! function_exists( 'magicauth_hex_to_rgb' ) ) { + /** + * Parse a 3- or 6-digit hex color (leading `#` optional) into [r, g, b] + * ints (0–255). Returns null on malformed input. + * + * @return array{0:int,1:int,2:int}|null + */ + function magicauth_hex_to_rgb( string $hex ): ?array { $hex = ltrim( $hex, '#' ); if ( 3 === strlen( $hex ) ) { $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; } if ( 6 !== strlen( $hex ) || ! ctype_xdigit( $hex ) ) { + return null; + } + return [ + (int) hexdec( substr( $hex, 0, 2 ) ), + (int) hexdec( substr( $hex, 2, 2 ) ), + (int) hexdec( substr( $hex, 4, 2 ) ), + ]; + } +} + +if ( ! function_exists( 'magicauth_yiq_text_color' ) ) { + /** Black or white text for a hex bg via YIQ luminance. */ + function magicauth_yiq_text_color( string $hex ): string { + $rgb = magicauth_hex_to_rgb( $hex ); + if ( null === $rgb ) { return '#ffffff'; } - $r = hexdec( substr( $hex, 0, 2 ) ); - $g = hexdec( substr( $hex, 2, 2 ) ); - $b = hexdec( substr( $hex, 4, 2 ) ); - $yiq = ( ( $r * 299 ) + ( $g * 587 ) + ( $b * 114 ) ) / 1000; + $yiq = ( ( $rgb[0] * 299 ) + ( $rgb[1] * 587 ) + ( $rgb[2] * 114 ) ) / 1000; return $yiq >= 128 ? '#000000' : '#ffffff'; } } +if ( ! function_exists( 'magicauth_contrast_ratio' ) ) { + /** + * WCAG 2.1 relative-luminance contrast ratio between two hex colors. + * + * Range: 1.0 (identical) to 21.0 (pure black on pure white). Uses ITU-R + * BT.709 coefficients and the sRGB gamma curve per the WCAG 2.1 spec. + * Accepts 3- or 6-digit hex with optional leading `#`. Returns 1.0 (the + * worst valid ratio) on malformed input — callers should pre-validate. + */ + function magicauth_contrast_ratio( string $hex_a, string $hex_b ): float { + $lum = static function ( string $hex ): ?float { + $rgb = magicauth_hex_to_rgb( $hex ); + if ( null === $rgb ) { + return null; + } + $channels = [ $rgb[0] / 255.0, $rgb[1] / 255.0, $rgb[2] / 255.0 ]; + foreach ( $channels as &$c ) { + $c = ( $c <= 0.03928 ) + ? $c / 12.92 + : pow( ( $c + 0.055 ) / 1.055, 2.4 ); + } + unset( $c ); + return ( 0.2126 * $channels[0] ) + ( 0.7152 * $channels[1] ) + ( 0.0722 * $channels[2] ); + }; + + $l1 = $lum( $hex_a ); + $l2 = $lum( $hex_b ); + if ( null === $l1 || null === $l2 ) { + return 1.0; + } + if ( $l2 > $l1 ) { + [ $l1, $l2 ] = [ $l2, $l1 ]; + } + return ( $l1 + 0.05 ) / ( $l2 + 0.05 ); + } +} + +if ( ! function_exists( 'magicauth_contrast_evaluate' ) ) { + /** + * Three-tier WCAG verdict on a contrast ratio. + * + * - 'fail' below 2.5:1 (unreadable territory; reject at sanitize). + * - 'warn' from 2.5:1 to context floor (AA 4.5:1 normal, 3:1 large/UI). + * - 'pass' at or above context floor. + * + * @param float $ratio Output of magicauth_contrast_ratio(). + * @param string $context 'normal'|'large'|'ui' — defaults to 'normal' (strictest). + */ + function magicauth_contrast_evaluate( float $ratio, string $context = 'normal' ): string { + $floor = 'normal' === $context ? 4.5 : 3.0; + if ( $ratio < 2.5 ) { + return 'fail'; + } + if ( $ratio < $floor ) { + return 'warn'; + } + return 'pass'; + } +} + if ( ! function_exists( 'magicauth_current_user_can_control_user' ) ) { /** Cap gate: edit_user + same-or-higher role. Filterable for custom hierarchies. */ function magicauth_current_user_can_control_user( int $target_user_id ): bool { diff --git a/magicauth.php b/magicauth.php index 21db5cb..401a33f 100644 --- a/magicauth.php +++ b/magicauth.php @@ -1,7 +1,7 @@ MagicAuth. +* Branding: new color knobs are validated against WCAG 2.1 AA contrast at save time — accidents below the readability floor are rejected, low-contrast values save with a notice, AA+ values save silently. +* UX: Branding section now splits into "Identity" and "Appearance" sub-cards; all settings sections are collapsible via native `
`/``. + += 1.1.0 = +* Branding: page background color, card corner radius, card max width, and font family are now admin-controllable from Settings > MagicAuth. +* Security: `wp-login.php` now sends `X-Frame-Options: DENY` and `Content-Security-Policy: frame-ancestors 'none'` to refuse framing entirely. + = 1.0.0 = * Initial public release. diff --git a/templates/login-shell.php b/templates/login-shell.php index a1f118d..673eb21 100644 --- a/templates/login-shell.php +++ b/templates/login-shell.php @@ -19,17 +19,70 @@ defined( 'ABSPATH' ) || exit; ?> ' or a future maintainer can break the assertEqualsWithDelta( 21.0, magicauth_contrast_ratio( '#000', '#ffffff' ), 0.01 ); + } + + public function test_identical_colors_yield_1(): void { + $this->assertEqualsWithDelta( 1.0, magicauth_contrast_ratio( '#abcdef', '#abcdef' ), 0.001 ); + } + + public function test_wp_blue_on_white_is_aa_normal(): void { + $r = magicauth_contrast_ratio( '#2271b1', '#ffffff' ); + $this->assertGreaterThanOrEqual( 4.5, $r ); + $this->assertLessThan( 7.0, $r ); + } + + public function test_three_digit_hex_normalizes_like_six(): void { + $this->assertEqualsWithDelta( + magicauth_contrast_ratio( '#abc', '#ffffff' ), + magicauth_contrast_ratio( '#aabbcc', '#ffffff' ), + 0.01 + ); + } + + public function test_symmetric(): void { + $a = magicauth_contrast_ratio( '#123456', '#abcdef' ); + $b = magicauth_contrast_ratio( '#abcdef', '#123456' ); + $this->assertEqualsWithDelta( $a, $b, 0.001 ); + } + + public function test_garbage_input_returns_1(): void { + $this->assertSame( 1.0, magicauth_contrast_ratio( 'pirate', '#ffffff' ) ); + } + + public function test_alpha_hex_rejected(): void { + // 8-char form is not a valid 3/6-digit hex → 1.0 sentinel. + $this->assertSame( 1.0, magicauth_contrast_ratio( '#ff000080', '#ffffff' ) ); + } + + public function test_evaluate_thresholds(): void { + $this->assertSame( 'fail', magicauth_contrast_evaluate( 2.0, 'normal' ) ); + $this->assertSame( 'warn', magicauth_contrast_evaluate( 4.0, 'normal' ) ); + $this->assertSame( 'pass', magicauth_contrast_evaluate( 5.0, 'normal' ) ); + $this->assertSame( 'pass', magicauth_contrast_evaluate( 3.5, 'ui' ) ); + $this->assertSame( 'pass', magicauth_contrast_evaluate( 3.5, 'large' ) ); + } +} diff --git a/tests/phpunit/Model/SettingsSanitizeTest.php b/tests/phpunit/Model/SettingsSanitizeTest.php new file mode 100644 index 0000000..1a399bd --- /dev/null +++ b/tests/phpunit/Model/SettingsSanitizeTest.php @@ -0,0 +1,269 @@ +sanitize( [ 'page_color' => '#abcdef' ] ); + $this->assertSame( '#abcdef', $out['page_color'] ); + } + + public function test_page_color_falls_back_on_garbage(): void { + $out = $this->sanitize( [ 'page_color' => 'not a color' ] ); + $this->assertSame( '#eeeeee', $out['page_color'] ); + } + + public function test_page_color_array_input_is_ignored(): void { + $out = $this->sanitize( [ 'page_color' => [ '#ff0000' ] ] ); + // is_scalar guard short-circuits; current default survives. + $this->assertSame( '#eeeeee', $out['page_color'] ); + } + + public function test_card_radius_clamps_above_max(): void { + $out = $this->sanitize( [ 'card_radius' => 9999 ] ); + $this->assertSame( 32, $out['card_radius'] ); + } + + public function test_card_radius_accepts_bounds(): void { + $lo = $this->sanitize( [ 'card_radius' => 0 ] ); + $hi = $this->sanitize( [ 'card_radius' => 32 ] ); + $this->assertSame( 0, $lo['card_radius'] ); + $this->assertSame( 32, $hi['card_radius'] ); + } + + public function test_card_radius_garbage_yields_zero(): void { + $out = $this->sanitize( [ 'card_radius' => 'foo' ] ); + $this->assertSame( 0, $out['card_radius'] ); + } + + public function test_card_width_clamps_below_min(): void { + $out = $this->sanitize( [ 'card_width' => 100 ] ); + $this->assertSame( 360, $out['card_width'] ); + } + + public function test_card_width_clamps_above_max(): void { + $out = $this->sanitize( [ 'card_width' => 9999 ] ); + $this->assertSame( 640, $out['card_width'] ); + } + + public function test_card_width_accepts_bounds(): void { + $lo = $this->sanitize( [ 'card_width' => 360 ] ); + $hi = $this->sanitize( [ 'card_width' => 640 ] ); + $this->assertSame( 360, $lo['card_width'] ); + $this->assertSame( 640, $hi['card_width'] ); + } + + public function test_font_stack_round_trips_allowlist(): void { + foreach ( [ 'system', 'sans-modern', 'serif', 'mono', 'rounded' ] as $key ) { + $out = $this->sanitize( [ 'font_stack' => $key ] ); + $this->assertSame( $key, $out['font_stack'], "key '{$key}' should round-trip" ); + } + } + + public function test_font_stack_rejects_unknown_key(): void { + $out = $this->sanitize( [ 'font_stack' => 'pirate' ] ); + $this->assertSame( 'system', $out['font_stack'] ); + } + + public function test_font_stack_rejects_array_input(): void { + // is_scalar guard rejects array entirely; default survives. + $out = $this->sanitize( [ 'font_stack' => [ 'rounded' ] ] ); + $this->assertSame( 'system', $out['font_stack'] ); + } + + public function test_defaults_returned_for_missing_keys(): void { + // Simulates v1.0 -> v1.1 upgrade: stored option has no new keys. + update_option( 'magicauth_settings', [ 'ttl_minutes' => 10 ] ); + $settings = magicauth_get_settings(); + $this->assertSame( '#eeeeee', $settings['page_color'] ); + $this->assertSame( 6, $settings['card_radius'] ); + $this->assertSame( 480, $settings['card_width'] ); + $this->assertSame( 'system', $settings['font_stack'] ); + } + + public function test_link_color_accepts_empty(): void { + $out = $this->sanitize( [ 'link_color' => '' ] ); + $this->assertSame( '', $out['link_color'] ); + $this->assertEmpty( $this->settings_error_codes(), 'empty link_color should produce no notice' ); + } + + public function test_link_color_accepts_high_contrast_hex(): void { + $out = $this->sanitize( [ 'link_color' => '#0044aa' ] ); + $this->assertSame( '#0044aa', $out['link_color'] ); + $this->assertEmpty( $this->settings_error_codes(), 'AA+ link_color should be silent' ); + } + + public function test_link_color_rejects_invalid_hex(): void { + $out = $this->sanitize( [ 'link_color' => 'pirate' ] ); + // Default link_color is empty; invalid input restores previous value. + $this->assertSame( '', $out['link_color'] ); + $this->assertContains( 'magicauth_link_color_invalid', $this->settings_error_codes() ); + } + + public function test_link_color_rejects_unreadable_below_floor(): void { + // #bbbbbb on #ffffff ≈ 1.9:1 — well under the 2.5 hard floor. + $out = $this->sanitize( [ 'link_color' => '#bbbbbb' ] ); + $this->assertSame( '', $out['link_color'], 'fail verdict must restore previous (empty default)' ); + $this->assertContains( 'magicauth_link_color_unreadable', $this->settings_error_codes() ); + } + + public function test_link_color_saves_with_warning_in_warn_band(): void { + // #888888 on #ffffff ≈ 3.5:1 — between the 2.5 floor and AA 4.5. + $out = $this->sanitize( [ 'link_color' => '#888888' ] ); + $this->assertSame( '#888888', $out['link_color'], 'warn verdict must still save' ); + $this->assertContains( 'magicauth_link_color_low_contrast', $this->settings_error_codes() ); + } + + public function test_link_color_array_input_is_ignored(): void { + $out = $this->sanitize( [ 'link_color' => [ '#ff0000' ] ] ); + $this->assertSame( '', $out['link_color'] ); + } + + public function test_color_mode_round_trips_allowlist(): void { + foreach ( [ 'light', 'dark', 'auto' ] as $mode ) { + $out = $this->sanitize( [ 'color_mode' => $mode ] ); + $this->assertSame( $mode, $out['color_mode'], "mode '{$mode}' should round-trip" ); + } + } + + public function test_color_mode_rejects_unknown(): void { + $out = $this->sanitize( [ 'color_mode' => 'midnight' ] ); + $this->assertSame( 'light', $out['color_mode'] ); + } + + public function test_color_mode_rejects_array_input(): void { + $out = $this->sanitize( [ 'color_mode' => [ 'dark' ] ] ); + // Default survives via is_scalar guard. + $this->assertSame( 'light', $out['color_mode'] ); + } + + public function test_brand_dark_contrast_warning_fires_for_low_contrast(): void { + $out = $this->sanitize( [ + 'brand_color' => '#444444', + 'color_mode' => 'dark', + ] ); + $this->assertSame( '#444444', $out['brand_color'] ); + $this->assertSame( 'dark', $out['color_mode'] ); + $this->assertContains( 'magicauth_brand_dark_contrast', $this->settings_error_codes() ); + } + + public function test_brand_dark_contrast_silent_in_light_mode(): void { + // Same low-contrast brand, but light mode — no dark-surface check applies. + $out = $this->sanitize( [ + 'brand_color' => '#444444', + 'color_mode' => 'light', + ] ); + $this->assertNotContains( 'magicauth_brand_dark_contrast', $this->settings_error_codes() ); + } + + public function test_background_id_zero_clears(): void { + $out = $this->sanitize( [ 'background_attachment_id' => 0 ] ); + $this->assertSame( 0, $out['background_attachment_id'] ); + $this->assertEmpty( $this->settings_error_codes(), 'zero should be silent' ); + } + + public function test_background_id_rejects_non_image_attachment(): void { + $path = $this->make_temp_file( '.png', $this->one_pixel_png_bytes() ); + try { + magicauth_test_register_attachment_file( 42, $path, false /* is_image = false */ ); + $out = $this->sanitize( [ 'background_attachment_id' => 42 ] ); + $this->assertSame( 0, $out['background_attachment_id'] ); + $this->assertContains( 'magicauth_bg_not_image', $this->settings_error_codes() ); + } finally { + @unlink( $path ); + } + } + + public function test_background_id_accepts_valid_png(): void { + $path = $this->make_temp_file( '.png', $this->one_pixel_png_bytes() ); + try { + magicauth_test_register_attachment_file( 7, $path, true ); + $out = $this->sanitize( [ 'background_attachment_id' => 7 ] ); + $this->assertSame( 7, $out['background_attachment_id'] ); + $this->assertEmpty( $this->settings_error_codes(), 'valid PNG should be silent' ); + } finally { + @unlink( $path ); + } + } + + public function test_background_id_rejects_svg(): void { + $path = $this->make_temp_file( '.svg', '' ); + try { + magicauth_test_register_attachment_file( 11, $path, true ); + $out = $this->sanitize( [ 'background_attachment_id' => 11 ] ); + $this->assertSame( 0, $out['background_attachment_id'] ); + $this->assertContains( 'magicauth_bg_bad_ext', $this->settings_error_codes() ); + } finally { + @unlink( $path ); + } + } + + public function test_background_id_returns_zero_when_path_missing(): void { + // Image flag set but no path registered → get_attached_file() returns false. + global $magicauth_test_state; + $magicauth_test_state['attachment_is_image'][ 99 ] = true; + $out = $this->sanitize( [ 'background_attachment_id' => 99 ] ); + $this->assertSame( 0, $out['background_attachment_id'] ); + } + + private function make_temp_file( string $extension, string $bytes ): string { + $path = sys_get_temp_dir() . '/magicauth-bg-test-' . uniqid( '', true ) . $extension; + file_put_contents( $path, $bytes ); + return $path; + } + + private function one_pixel_png_bytes(): string { + // Minimal 1×1 transparent PNG — finfo identifies as image/png. + return base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', + true + ) ?: ''; + } + + /** @return list */ + private function settings_error_codes(): array { + $errors = $GLOBALS['magicauth_test_state']['settings_errors'] ?? []; + return array_map( static fn ( $e ) => (string) ( $e['code'] ?? '' ), $errors ); + } + + public function test_font_stacks_in_shell_template_contain_no_html_meta_chars(): void { + // HTML parsers treat '' as a block-terminator regardless of CSS + // quoting. Pin a static assertion so a future maintainer adding e.g. + // 'fancy' => 'Helvetica' cannot silently break the