//! Negative regression tests for the JWT verification engine.
//!
//! Each `#[test]` in this file pins one mitigation row from
//! `0context/STANDARDS_JWT_DETAILS_MITIGATION_PPOPPO.md` §4. Test name
//! mirrors the matrix `Test name` column verbatim — the CI drift check
//! (matrix §2.4) greps for these names. Renaming a test without flipping
//! the matrix row is a regression.
//!
//! Phase 1 covers M01–M16a (algorithm + header attack surface). Subsequent
//! phases append modules; no test is ever deleted (a `[X]` → `[~]`
//! transition keeps the test and converts it to a "this is allowed by
//! policy" assertion per matrix §2.5).

#![allow(clippy::unwrap_used, clippy::expect_used)]

use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as B64};
use jsonwebtoken::{DecodingKey, EncodingKey, Header};
use ppoppo_token::{AuthError, KeySet, VerifyConfig, verify};
use serde_json::json;

// ── Forge primitives ─────────────────────────────────────────────────────
//
// Two forge styles coexist:
//
// 1. `forge_with_header` — structural-only forge with a literal `<sig>`
//    placeholder. Phase 1 negative tests (M01-M16a) reject before any
//    signature check, so the placeholder is fine and keeps tests cheap.
//
// 2. `forge_signed_token` — real Ed25519-signed Compact JWS using the
//    fixed test keypair below. Sub-cycle B M-row tests (M17-M30) need a
//    token that survives header validation so claim-level enforcement can
//    fire; they forge with this helper and verify against `test_keyset()`.

fn b64<T: serde::Serialize>(value: &T) -> String {
    B64.encode(serde_json::to_vec(value).unwrap())
}

fn forge_with_header(header: &serde_json::Value, payload: &serde_json::Value) -> String {
    format!("{}.{}.<sig>", b64(header), b64(payload))
}

/// `sub` is `ppnum_id` (ULID, 26-char Crockford base32) per RFC §6.5.
/// Phase 4 commit 4.1 (M39) starts enforcing this format on the verify
/// side. Pre-existing baseline kept an 11-digit string here from a much
/// earlier draft that conflated `ppnum` (digit display id) with `ppnum_id`
/// (ULID).
///
/// Crockford base32 excludes `I L O U` — using `01HSAB...` (S, A, B all
/// valid) instead of the spelled-out `01HSUB...` (`U` invalid) avoids the
/// look-alike footgun. Phase 3 round-trip fixtures used the broken form;
/// commit 4.1 fixes them in lockstep.
const TEST_SUB_ULID: &str = "01HSAB00000000000000000000";

fn default_payload() -> serde_json::Value {
    json!({
        "iss": "https://accounts.ppoppo.com",
        "sub": TEST_SUB_ULID,
        "aud": "ppoppo",
        "exp": 9999999999_i64,
        "iat": 1_700_000_000_i64,
        "jti": "01HABC00000000000000000000",
    })
}

fn cfg() -> VerifyConfig {
    VerifyConfig::access_token("https://accounts.ppoppo.com", "ppoppo")
}

// ── Signed-forge keypair (Ed25519, test-only) ────────────────────────────
//
// Generated once via `openssl genpkey -algorithm ed25519`. The private key
// has no production value and is checked in only so signed-forge tests are
// deterministic across CI runs and local replays. Production keys live in
// PAS's KMS and never appear in the repo.

const TEST_PRIVATE_KEY_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIG+00IvEd4uv6IWtGFVUEBVdqnXiuI/ESQHu6rmcDvAs
-----END PRIVATE KEY-----
";

const TEST_PUBLIC_KEY_PEM: &[u8] = b"-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAh//e6j3It3xhjghg8Kpn2pM0jMCH/cvemGu4vv7D1Q4=
-----END PUBLIC KEY-----
";

const TEST_KID: &str = "k4.test.0";

fn test_keyset() -> KeySet {
    let mut ks = KeySet::new();
    let dec =
        DecodingKey::from_ed_pem(TEST_PUBLIC_KEY_PEM).expect("test public key PEM should parse");
    ks.insert(TEST_KID, dec);
    ks
}

/// Forge a real Ed25519-signed JWS Compact token with the test keypair.
///
/// The header is fixed at the values Phase 1 checks accept (`alg=EdDSA`,
/// `typ=at+jwt`, `kid=TEST_KID`); the caller only chooses the payload.
/// Sub-cycle B per-row tests start from `signed_default_payload()` and
/// mutate the field under enforcement.
fn forge_signed_token(payload: &serde_json::Value) -> String {
    let mut header = Header::new(jsonwebtoken::Algorithm::EdDSA);
    header.kid = Some(TEST_KID.to_string());
    header.typ = Some("at+jwt".to_string());
    let enc =
        EncodingKey::from_ed_pem(TEST_PRIVATE_KEY_PEM).expect("test private key PEM should parse");
    jsonwebtoken::encode(&header, payload, &enc).expect("encode forge should succeed")
}

/// Default payload for signed-forge tests. Carries every registered claim
/// Phase 2 will deserialize. Per-row tests deviate from this baseline only
/// in the field under enforcement.
///
/// Timestamps are computed at call time so M18 (exp > now) and M19
/// (exp - iat ≤ 24h) both pass for the baseline. Per-row tests that need
/// an expired or far-future timestamp override the relevant field.
#[allow(dead_code)] // wired up incrementally in Sub-cycle B (M17-M30)
fn signed_default_payload() -> serde_json::Value {
    let now = time::OffsetDateTime::now_utc().unix_timestamp();
    json!({
        "iss": "https://accounts.ppoppo.com",
        "sub": TEST_SUB_ULID,
        "aud": "ppoppo",
        "exp": now + 3600,       // 1h from now — within M19's 24h cap
        "iat": now - 60,          // 1m ago — within M24's 60s leeway
        "jti": "01HABC00000000000000000000",
        "client_id": "ppoppo-internal",
        "cat": "access",
    })
}

// ── C. Claims (M17–M30) ──────────────────────────────────────────────────

/// M17 — RFC 8725 §3.10: `exp` is mandatory on every JWT. A token without
/// an expiry contract has no admissibility window, so the engine refuses
/// it before any value check can apply. Per Decision 2 of Phase 2 design,
/// the missing-exp signal is its own variant (`ExpMissing`) rather than a
/// generic `ClaimMissing("exp")` — audit logs read the M-ID off the
/// variant name without a lookup table.
#[tokio::test]
async fn test_require_exp() {
    let mut payload = signed_default_payload();
    payload
        .as_object_mut()
        .expect("default payload is a JSON object")
        .remove("exp");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::ExpMissing));
}

/// M18 — RFC 8725 §3.10: `exp` is enforced strictly (leeway = 0). A
/// token whose expiry timestamp precedes the current instant is rejected
/// regardless of how many other checks pass. Test uses a fixed past
/// timestamp (2023-11-14) so it stays expired no matter when CI runs.
#[tokio::test]
async fn test_reject_expired() {
    let mut payload = signed_default_payload();
    payload["exp"] = json!(1_700_000_000_i64); // 2023-11-14 — well in the past
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::Expired));
}

/// M19 — RFC 8725 §3.10 / RFC 9068 access-token profile: an access token
/// MUST NOT be issued with `exp - iat > 24h`. A malicious or
/// misconfigured issuer that mints near-immortal credentials is bounded
/// by the engine even when every other claim looks valid. Phase 2 covers
/// the access-token cap (24h); the refresh cap (200d) is wired when
/// refresh issuance lands in Phase 4.
#[tokio::test]
async fn test_reject_far_future_exp() {
    let mut payload = signed_default_payload();
    let now = time::OffsetDateTime::now_utc().unix_timestamp();
    payload["iat"] = json!(now - 60);
    payload["exp"] = json!(now + 25 * 3600); // 25 hours — exceeds the 24h cap
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::ExpUpperBound));
}

/// M20 — RFC 8725 §3.11: `aud` is mandatory. Without an audience binding
/// the engine cannot enforce the verifier-specific match (M21/M22). The
/// matrix splits "missing" (M20) from "value mismatch" (M21/M22) so audit
/// logs distinguish "issuer never bound this token to anyone" from
/// "issuer bound to the wrong resource server".
#[tokio::test]
async fn test_require_aud() {
    let mut payload = signed_default_payload();
    payload
        .as_object_mut()
        .expect("default payload is a JSON object")
        .remove("aud");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::AudMissing));
}

/// M21 — RFC 8725 §3.11: `aud` (string form) must exactly match the
/// verifier's expected audience. A token bound to a different resource
/// server than the verifier protects is the canonical audience-confusion
/// vector — the verifier MUST refuse it even when the issuer's signature
/// is valid.
#[tokio::test]
async fn test_reject_aud_mismatch() {
    let mut payload = signed_default_payload();
    payload["aud"] = json!("some-other-resource-server");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::AudMismatch));
}

/// M22 — RFC 7519 §4.1.3 / RFC 8725 §3.11: `aud` MAY be an array. When
/// it is, the verifier's expected audience must match at least one
/// entry. The wire-shape variation (string vs array) is hidden inside
/// `engine::check_claims` per Phase 2 design memory — callers never see
/// `OneOrMany<String>` on the public surface.
#[tokio::test]
async fn test_aud_array_any_match() {
    let mut payload = signed_default_payload();
    payload["aud"] = json!(["some-other-resource", "ppoppo", "another-one"]);
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert!(
        result.is_ok(),
        "aud array containing cfg.audience must be accepted; got {result:?}"
    );
}

/// M23 — RFC 7519 §4.1.1: `iss` MUST equal the pinned issuer
/// (`https://accounts.ppoppo.com` for ppoppo's PAS). Missing-iss and
/// wrong-iss collapse into the same variant because the audit signal is
/// identical — the token did not come from the trusted issuer.
#[tokio::test]
async fn test_require_iss_pinned() {
    let mut payload = signed_default_payload();
    payload["iss"] = json!("https://attacker.example.com");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::IssMismatch));
}

/// M24 — RFC 7519 §4.1.6: `iat` must be in the past, with a 60s
/// clock-skew leeway to absorb minor desync between issuer and verifier.
/// A token with iat far in the future is either misconfigured or a
/// crude post-dating attempt.
#[tokio::test]
async fn test_require_iat_in_past() {
    let mut payload = signed_default_payload();
    let now = time::OffsetDateTime::now_utc().unix_timestamp();
    payload["iat"] = json!(now + 120); // 2 minutes ahead — beyond 60s leeway
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::IatFuture));
}

/// M25 — RFC 7519 §4.1.6: `iat` upper bound. Distinct matrix row from
/// M24 but the engine enforces both via the same predicate
/// (`iat > now + 60s`) — Phase 2 design memory's documented-collapse.
/// The test exercises a far-future iat (well beyond M24's 60s leeway)
/// to make the upper-bound semantics explicit at the test layer; engine
/// behavior is identical.
#[tokio::test]
async fn test_reject_future_iat() {
    let mut payload = signed_default_payload();
    let now = time::OffsetDateTime::now_utc().unix_timestamp();
    payload["iat"] = json!(now + 25 * 3600); // 25h ahead — clearly not desync
    payload["exp"] = json!(now + 26 * 3600); // keep exp > iat for M18
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::IatFuture));
}

/// M26 — RFC 7519 §4.1.5: `nbf` (not-before) is the issuer's explicit
/// "valid from" boundary. When present and in the future, the token has
/// not yet entered its admissibility window and MUST be refused. nbf is
/// optional in the spec — absence is not an error.
#[tokio::test]
async fn test_reject_premature_nbf() {
    let mut payload = signed_default_payload();
    let now = time::OffsetDateTime::now_utc().unix_timestamp();
    payload["nbf"] = json!(now + 600); // 10 minutes in the future
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::NotYetValid));
}

/// M27 — RFC 7519 §4.1.7: `jti` is the unique token identifier and the
/// replay-cache key (M35). Without it the engine cannot enforce
/// one-shot semantics on per-token operations.
#[tokio::test]
async fn test_require_jti() {
    let mut payload = signed_default_payload();
    payload
        .as_object_mut()
        .expect("default payload is a JSON object")
        .remove("jti");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::JtiMissing));
}

/// M28 — RFC 7519 §4.1.2: `sub` identifies the principal the token is
/// about. No useful authorization decision can follow when sub is
/// absent.
#[tokio::test]
async fn test_require_sub() {
    let mut payload = signed_default_payload();
    payload
        .as_object_mut()
        .expect("default payload is a JSON object")
        .remove("sub");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::SubMissing));
}

/// M28a — RFC 9068 §2.2 mandates `client_id` on every access JWT so the
/// resource server can identify the originating OAuth client (audit,
/// per-client rate limits, scope-narrowing).
#[tokio::test]
async fn test_require_client_id() {
    let mut payload = signed_default_payload();
    payload
        .as_object_mut()
        .expect("default payload is a JSON object")
        .remove("client_id");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::ClientIdMissing));
}

/// M29 — RFC 9068 §2.2 + ppoppo extension: `cat` (token category) is a
/// payload-level discriminator that lets the verifier refuse type
/// confusion. Phase 2 verifies access tokens only; a refresh-cat token
/// presented at an access endpoint must be refused even when every
/// other claim is well-formed.
#[tokio::test]
async fn test_reject_token_type_mismatch() {
    let mut payload = signed_default_payload();
    payload["cat"] = json!("refresh"); // wrong category for the access profile
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::TokenTypeMismatch));
}

/// M30 — RFC 8725 §2.4: numeric claims (`exp`/`iat`/`nbf`) MUST be JSON
/// integers, not strings. String-coerced numerics are a classic
/// substitution vector — a forger sets `"exp": "9999"` (string) hoping
/// the verifier parses it loosely as a far-future expiry. The engine
/// refuses non-integer numerics before any value-violation rule fires.
#[tokio::test]
async fn test_reject_string_numeric_claim() {
    let mut payload = signed_default_payload();
    payload["exp"] = json!("9999"); // string, not integer
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::InvalidNumericType));
}

// ── F. Domain (M39–M45) ──────────────────────────────────────────────────

/// M45 — RFC §6.5: payload claims MUST be in the engine's strict
/// allowlist. PII (`email`, `phone`, `name`) is the canonical thing to
/// keep OUT of the payload — base64-decoded JWT bodies are semi-public
/// (anyone holding the token can read them), so caching email there
/// would leak it to every downstream consumer that logs the token.
/// Forces the email lookup through PAS userinfo (where ACLs apply).
///
/// The allowlist also acts as a catch-net for forgery / smuggling: any
/// attacker-injected claim (`x_marker: ...`) gets rejected with the
/// claim name in the audit signal.
#[tokio::test]
async fn test_reject_pii_in_payload() {
    for pii_key in ["email", "phone", "name"] {
        let mut payload = signed_default_payload();
        payload[pii_key] = json!("alice@example.com");
        let token = forge_signed_token(&payload);
        let result = verify(&token, &cfg(), &test_keyset()).await;
        assert_eq!(
            result,
            Err(AuthError::UnknownClaim(pii_key.to_string())),
            "expected UnknownClaim({pii_key:?}); got {result:?}",
        );
    }
}

/// M45 — attacker-injected smuggling claim. The allowlist refuses
/// arbitrary keys, not just PII names; any claim outside the
/// engine-known set is a forgery signal.
#[tokio::test]
async fn test_reject_smuggled_claim() {
    let mut payload = signed_default_payload();
    payload["x_attacker_marker"] = json!(1);
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(
        result,
        Err(AuthError::UnknownClaim("x_attacker_marker".to_string())),
    );
}

/// M44 — RFC §6.5: a token with `admin: true` MUST carry an
/// `active_ppnum` whose first 3 digits fall in the admin allocation
/// band. Defense-in-depth on top of the DB-side `is_admin` lookup
/// (STANDARDS_AUTH_PPOPPO §3.2 — DB is the source of truth); the
/// band check narrows a stolen-key forgery surface from "any ppnum"
/// to "an admin-banded ppnum". Phase 4 hardcodes the band at
/// `[100, 109]`; Phase 5+ may load the allowlist from cfg.
#[tokio::test]
async fn test_admin_token_band_pinned() {
    // (a) admin=true with non-admin band — `200-...` is in user band.
    let mut payload = signed_default_payload();
    payload["admin"] = json!(true);
    payload["active_ppnum"] = json!("20012345678");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::AdminBandRejected));

    // (b) admin=true without active_ppnum at all — engine cannot
    //     prove the band, so refuse rather than silently admit.
    let mut payload = signed_default_payload();
    payload["admin"] = json!(true);
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::AdminBandRejected));

    // (c) admin=true with active_ppnum in admin band — admitted.
    let mut payload = signed_default_payload();
    payload["admin"] = json!(true);
    payload["active_ppnum"] = json!("10012345678"); // first 3 digits = 100 ∈ [100,109]
    let token = forge_signed_token(&payload);
    assert!(verify(&token, &cfg(), &test_keyset()).await.is_ok());

    // (d) admin=false (default) — band check is skipped entirely;
    //     non-admin tokens can have any ppnum. Pinned alongside (c)
    //     so a future drift that accidentally checks band on every
    //     token (a "fail closed" overreach) fails loudly.
    let mut payload = signed_default_payload();
    payload["active_ppnum"] = json!("20012345678");
    let token = forge_signed_token(&payload);
    assert!(verify(&token, &cfg(), &test_keyset()).await.is_ok());
}

/// M43 — RFC §6.5: `dlg_depth` ∈ [0, 4]. Bounds the audit-trail
/// explosion of arbitrarily deep Token Exchange chains. Absence and
/// `0` both mean "no delegation" — the engine collapses them at the
/// wire (`0` is omitted on emission per the default-deny pattern).
/// Test pins the inclusive upper bound (4 admits, 5 rejects) and
/// the wire-shape forgery vector (string "5" rejected).
#[tokio::test]
async fn test_reject_deep_delegation() {
    // (a) value over the bound
    let mut payload = signed_default_payload();
    payload["dlg_depth"] = json!(5);
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::DlgDepthInvalid));

    // (b) negative value — an unsigned counter cannot be negative; a
    //     present-but-negative dlg_depth is a forgery / misconfiguration
    let mut payload = signed_default_payload();
    payload["dlg_depth"] = json!(-1);
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::DlgDepthInvalid));

    // (c) string-coerced numeric — same M30-style substitution attack
    //     as numeric claims; collapsed into M43's variant because the
    //     rejection happens after registered-claim checks pass.
    let mut payload = signed_default_payload();
    payload["dlg_depth"] = json!("3");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::DlgDepthInvalid));
}

/// M43 boundary — `dlg_depth = 4` is admitted (inclusive bound).
#[tokio::test]
async fn test_dlg_depth_at_bound_admitted() {
    let mut payload = signed_default_payload();
    payload["dlg_depth"] = json!(4);
    let token = forge_signed_token(&payload);
    assert!(verify(&token, &cfg(), &test_keyset()).await.is_ok());
}

/// M42 (length) — RFC §6.5: `scopes` capped at ≤ 256 entries. Bounds
/// the audit-surface explosion of arbitrarily-large scope arrays and
/// caps the per-request scope-check cost. The boundary value (256) is
/// tested as positive; 257 is the canonical failing case.
#[tokio::test]
async fn test_reject_scopes_over_256() {
    let mut payload = signed_default_payload();
    let too_many: Vec<String> = (0..257).map(|i| format!("scope-{i}")).collect();
    payload["scopes"] = json!(too_many);
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::ScopesTooLong));
}

/// M42 (boundary) — exactly 256 entries is admitted (the bound is
/// inclusive). 0-255 also pass; the negative test pins the exclusive
/// upper edge.
#[tokio::test]
async fn test_scopes_at_256_admitted() {
    let mut payload = signed_default_payload();
    let exactly_max: Vec<String> = (0..256).map(|i| format!("scope-{i}")).collect();
    payload["scopes"] = json!(exactly_max);
    let token = forge_signed_token(&payload);
    assert!(verify(&token, &cfg(), &test_keyset()).await.is_ok());
}

/// M42 (shape) — `scopes` MUST be a JSON array of strings. Mirrors M41
/// but with its own variant; audit signal is "scope confusion attack"
/// vs "capability confusion attack".
#[tokio::test]
async fn test_reject_scopes_non_array() {
    let mut payload = signed_default_payload();
    payload["scopes"] = json!("read write"); // OAuth-spec space-delim — wrong for ppoppo
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::ScopesShapeInvalid));
}

/// M41 — RFC §6.5: `caps` MUST be a JSON array of strings when present.
/// Absence and empty array both mean "no capabilities" (default-deny).
/// The engine validates only the wire shape; semantic enforcement of
/// individual capability strings is per-surface (PAS / PCS / RCW each
/// own their capability vocabulary). Rejecting `caps: "admin"`
/// (string) defends against the "verifier reads as one-element list"
/// confusion vector.
#[tokio::test]
async fn test_reject_caps_non_array() {
    // (a) string instead of array — canonical forgery shape
    let mut payload = signed_default_payload();
    payload["caps"] = json!("admin");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::CapsShapeInvalid));

    // (b) array but with a non-string element — would let a forger
    //     embed numbers / objects / nested arrays
    let mut payload = signed_default_payload();
    payload["caps"] = json!(["read", 1, "write"]);
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::CapsShapeInvalid));

    // (c) object — rejected as not an array
    let mut payload = signed_default_payload();
    payload["caps"] = json!({"admin": true});
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::CapsShapeInvalid));
}

/// M41 positive — empty array is admitted (default-deny semantic).
/// Absence is also admitted (already covered by the default fixture).
#[tokio::test]
async fn test_caps_empty_array_admitted() {
    let mut payload = signed_default_payload();
    payload["caps"] = json!([]);
    let token = forge_signed_token(&payload);
    assert!(verify(&token, &cfg(), &test_keyset()).await.is_ok());
}

/// M40 — RFC §6.5: `account_type` ∈ `{"human", "ai_agent"}` when present.
/// The claim is optional (legacy tokens minted before the field existed
/// are admitted); but if a token CLAIMS to belong to a recognized
/// principal class and uses an unknown value, the engine refuses — the
/// issuer never emits free-form strings here, so any non-whitelist value
/// is a forgery signal. Three negative cases (typo / new-class / empty)
/// share the same variant; audit logs disambiguate via the rejected value.
#[tokio::test]
async fn test_reject_invalid_account_type() {
    for bad in ["superuser", "Human" /* case-sensitive */, ""] {
        let mut payload = signed_default_payload();
        payload["account_type"] = json!(bad);
        let token = forge_signed_token(&payload);
        let result = verify(&token, &cfg(), &test_keyset()).await;
        assert_eq!(
            result,
            Err(AuthError::AccountTypeInvalid),
            "expected AccountTypeInvalid for value {bad:?}; got {result:?}",
        );
    }
}

/// M40 positive — both whitelist values + claim absence are admitted.
/// Pinned alongside the negative test so a future drift that
/// accidentally narrows the whitelist (e.g. dropping `ai_agent`) fails
/// loudly rather than silently breaking AI-agent tokens.
#[tokio::test]
async fn test_account_type_whitelist_accepts_human_and_ai_agent() {
    for good in ["human", "ai_agent"] {
        let mut payload = signed_default_payload();
        payload["account_type"] = json!(good);
        let token = forge_signed_token(&payload);
        assert!(
            verify(&token, &cfg(), &test_keyset()).await.is_ok(),
            "expected {good:?} to pass M40",
        );
    }
    // Absence is also OK (legacy admit).
    let payload = signed_default_payload();
    let token = forge_signed_token(&payload);
    assert!(
        verify(&token, &cfg(), &test_keyset()).await.is_ok(),
        "absent account_type must pass M40 (legacy admit)",
    );
}

/// M39 — RFC §6.5: `sub` MUST be a 26-character Crockford-base32 ULID
/// (`^[0-9A-HJKMNP-TV-Z]{26}$`). PAS-issued tokens carry `ppnum_id`
/// (Human) or an AI-agent ULID; any other shape is either issuer drift
/// or forgery. The check is unconditional — even legacy tokens minted
/// with the 11-digit `ppnum` (display) string get rejected, forcing the
/// migration to `ppnum_id` to actually happen.
#[tokio::test]
async fn test_reject_non_ulid_sub() {
    // (a) too-short sub — 11 digit ppnum-style (legacy mistake)
    let mut payload = signed_default_payload();
    payload["sub"] = json!("00000000000");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::SubFormatInvalid));

    // (b) right length but invalid Crockford alphabet (`I`, `L`, `O`, `U`
    //     are excluded). 26-char string with `I` at the start exercises
    //     the alphabet check independently of the length check.
    let mut payload = signed_default_payload();
    payload["sub"] = json!("I1HSUB00000000000000000000"); // 'I' not in Crockford
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::SubFormatInvalid));

    // (c) free-form garbage — neither ppnum nor ULID
    let mut payload = signed_default_payload();
    payload["sub"] = json!("not-a-ulid");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::SubFormatInvalid));
}

// ── D. Serialization (M31–M34) ───────────────────────────────────────────

/// M31 — RFC 8725 §2.4: the profile accepts JWS Compact only. Tokens
/// presented in JWS JSON serialization (a JSON object, not a 3-segment
/// dotted string) are rejected before any segment parser runs. The
/// JSON form expands the implementation surface and has historically
/// carried polyglot-payload attacks.
#[tokio::test]
async fn test_reject_jws_json_serialization() {
    let json_form = r#"{"payload":"eyJpc3MiOiJodHRwczovL2FjY291bnRzLnBwb3Bwby5jb20ifQ","signatures":[{"protected":"eyJhbGciOiJFZERTQSJ9","signature":"c2ln"}]}"#;
    let result = verify(json_form, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::JwsJsonRejected));
}

/// M32 — RFC 7515 §3: a JSON object with duplicate keys MUST be
/// rejected. serde_json's default behavior silently keeps the last
/// occurrence, hiding the smuggling case where a forger duplicates a
/// claim hoping the verifier reads one value while a downstream
/// consumer reads another. The forge here builds the payload bytes by
/// hand because `serde_json::json!` deduplicates at construction time.
#[tokio::test]
async fn test_reject_duplicate_json_keys() {
    let now = time::OffsetDateTime::now_utc().unix_timestamp();
    let header_b64 = b64(&json!({"alg":"EdDSA","typ":"at+jwt","kid":TEST_KID}));
    let raw_payload = format!(
        r#"{{"iss":"https://accounts.ppoppo.com","sub":"a","sub":"b","aud":"ppoppo","exp":{},"iat":{},"jti":"01HABC00000000000000000000","client_id":"x","cat":"access"}}"#,
        now + 3600,
        now - 60
    );
    let payload_b64 = B64.encode(raw_payload.as_bytes());
    let token = format!("{header_b64}.{payload_b64}.<sig>");

    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::DuplicateJsonKeys));
}

/// M33 — RFC 8725 §2.4: segments MUST use strict base64url
/// (URL_SAFE_NO_PAD; only `A-Z a-z 0-9 - _`). Standard-base64 chars
/// (`+`, `/`, `=`) are refused with their own variant so audit logs
/// distinguish intentional injection from generic decode failure.
#[tokio::test]
async fn test_reject_lax_base64() {
    let header_b64 = b64(&json!({"alg":"EdDSA","typ":"at+jwt","kid":TEST_KID}));
    let bad_header = format!("{header_b64}+"); // '+' is not in URL_SAFE_NO_PAD
    let token = format!("{bad_header}.payload.<sig>");

    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::LaxBase64));
}

/// M34 — total token length cap. The access-token profile uses 8 KB by
/// default (`cfg.max_token_size`). An oversized token is either a
/// misconfigured issuer or a parser-amplification DoS vector; the
/// engine refuses it before any segment parsing.
#[tokio::test]
async fn test_reject_oversized_token() {
    let mut payload = signed_default_payload();
    payload["padding"] = json!("a".repeat(10_000)); // pushes the token > 8 KB
    let token = forge_signed_token(&payload);
    assert!(token.len() > 8 * 1024, "padded token must exceed 8 KB");

    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert_eq!(result, Err(AuthError::OversizedToken));
}

/// Smoke test for the signed-forge primitive — pins the contract that
/// Sub-cycle B relies on: a token forged with the test keypair, valid
/// `at+jwt` typ, and a kid registered in `test_keyset()` survives every
/// Phase 1 checkpoint (alg + header). Signature verification itself lands
/// when Sub-cycle C wires `jsonwebtoken::decode`; until then this test
/// asserts the *structural* contract only.
#[tokio::test]
async fn signed_forge_passes_phase1_skeleton() {
    let token = forge_signed_token(&signed_default_payload());

    let parts: Vec<&str> = token.split('.').collect();
    assert_eq!(parts.len(), 3, "JWS Compact MUST be 3 segments");
    assert!(!parts[2].is_empty(), "signature segment must be non-empty");

    let result = verify(&token, &cfg(), &test_keyset()).await;
    assert!(
        result.is_ok(),
        "forged signed token must pass Phase 1 verify; got {result:?}"
    );
}

// ── A. Algorithm (M01–M06) ───────────────────────────────────────────────

/// M01 — RFC 8725 §3.1: a token with `alg: none` is the canonical JWT
/// disaster. The library can't even parse it (no `Algorithm::None` variant
/// in jsonwebtoken 9.3.1), but the engine maps the failure to a *specific*
/// `AuthError::AlgNone` so audit logs distinguish "alg=none attack" from
/// "alg field garbled".
#[tokio::test]
async fn test_reject_alg_none() {
    let token = forge_with_header(
        &json!({ "alg": "none", "typ": "at+jwt" }),
        &default_payload(),
    );
    let result = verify(&token, &cfg(), &KeySet::new()).await;
    assert_eq!(result, Err(AuthError::AlgNone));
}

/// M02 — RFC 8725 §3.1: only algorithms in the per-request whitelist are
/// accepted. Even values the JOSE library happily parses (here: an
/// unknown identifier `FOO`) are rejected because the *configuration* is
/// the source of truth, not the header.
#[tokio::test]
async fn test_reject_alg_outside_whitelist() {
    let token = forge_with_header(
        &json!({ "alg": "FOO", "typ": "at+jwt" }),
        &default_payload(),
    );
    let result = verify(&token, &cfg(), &KeySet::new()).await;
    assert_eq!(result, Err(AuthError::AlgNotWhitelisted));
}

/// M03 — RFC 8725 §3.1: HMAC algorithms (`HS256`/`HS384`/`HS512`) are the
/// classic alg-confusion attack vector. An attacker who knows the public
/// key signs with HMAC; a naive verifier that picks the alg from the
/// header would treat the public key as a shared secret. The engine
/// rejects the entire family before any key resolution happens.
#[tokio::test]
async fn test_reject_hs_family_with_pubkey() {
    for alg in ["HS256", "HS384", "HS512"] {
        let token = forge_with_header(&json!({ "alg": alg, "typ": "at+jwt" }), &default_payload());
        let result = verify(&token, &cfg(), &KeySet::new()).await;
        assert_eq!(result, Err(AuthError::AlgHmacRejected), "alg={alg}");
    }
}

/// M04 — RFC 8725 §3.1: RSA family (`RS*` PKCS#1 v1.5, `PS*` PSS). EdDSA-
/// only profile excludes both — an issuer rotating off RSA must not have
/// any verifier silently accept legacy tokens.
#[tokio::test]
async fn test_reject_rs_family() {
    for alg in ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512"] {
        let token = forge_with_header(&json!({ "alg": alg, "typ": "at+jwt" }), &default_payload());
        let result = verify(&token, &cfg(), &KeySet::new()).await;
        assert_eq!(result, Err(AuthError::AlgRsaRejected), "alg={alg}");
    }
}

/// M05 — RFC 8725 §3.1: ECDSA family (`ES256`/`ES384`/`ES512`). Same
/// rationale as M04 — EdDSA-only profile.
#[tokio::test]
async fn test_reject_es_family() {
    for alg in ["ES256", "ES384", "ES512"] {
        let token = forge_with_header(&json!({ "alg": alg, "typ": "at+jwt" }), &default_payload());
        let result = verify(&token, &cfg(), &KeySet::new()).await;
        assert_eq!(result, Err(AuthError::AlgEcdsaRejected), "alg={alg}");
    }
}

// ── B. Header (M07–M16a) ─────────────────────────────────────────────────

/// M07 — RFC 8725 §3.5: `jku` (JWK Set URL) header tells a verifier to
/// fetch keys over the network. Honoring it would let an attacker host a
/// JWK Set on any controlled URL and have the verifier sign their forgery
/// with attacker-supplied keys. Engine rejects unconditionally.
#[tokio::test]
async fn test_reject_jku_header() {
    let token = forge_with_header(
        &json!({ "alg": "EdDSA", "typ": "at+jwt", "jku": "https://attacker.example/keys" }),
        &default_payload(),
    );
    let result = verify(&token, &cfg(), &KeySet::new()).await;
    assert_eq!(result, Err(AuthError::HeaderJku));
}

/// M08 — RFC 8725 §3.5: `x5u` is the X.509 analogue of `jku`. Same
/// remote-loading attack vector, same flat rejection.
#[tokio::test]
async fn test_reject_x5u_header() {
    let token = forge_with_header(
        &json!({ "alg": "EdDSA", "typ": "at+jwt", "x5u": "https://attacker.example/cert.pem" }),
        &default_payload(),
    );
    let result = verify(&token, &cfg(), &KeySet::new()).await;
    assert_eq!(result, Err(AuthError::HeaderX5u));
}

/// M09 — RFC 8725 §3.6: an inline `jwk` header lets a token bring its own
/// validation key. Trivially forgeable; engine rejects on presence alone.
#[tokio::test]
async fn test_reject_jwk_header() {
    let token = forge_with_header(
        &json!({
            "alg": "EdDSA", "typ": "at+jwt",
            "jwk": { "kty": "OKP", "crv": "Ed25519", "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" }
        }),
        &default_payload(),
    );
    let result = verify(&token, &cfg(), &KeySet::new()).await;
    assert_eq!(result, Err(AuthError::HeaderJwk));
}

/// M10 — RFC 8725 §3.6: `x5c` carries an inline X.509 chain. Same
/// self-validating-key risk as `jwk`; engine rejects on presence alone.
#[tokio::test]
async fn test_reject_x5c_header() {
    let token = forge_with_header(
        &json!({
            "alg": "EdDSA", "typ": "at+jwt",
            "x5c": ["MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA"]
        }),
        &default_payload(),
    );
    let result = verify(&token, &cfg(), &KeySet::new()).await;
    assert_eq!(result, Err(AuthError::HeaderX5c));
}

/// M11 — RFC 7515 §4.1.11: `crit` lists header extensions the verifier
/// MUST understand. The profile understands none, so any `crit` value is
/// a flat rejection.
#[tokio::test]
async fn test_reject_unknown_crit() {
    let token = forge_with_header(
        &json!({ "alg": "EdDSA", "typ": "at+jwt", "crit": ["b64"] }),
        &default_payload(),
    );
    let result = verify(&token, &cfg(), &KeySet::new()).await;
    assert_eq!(result, Err(AuthError::HeaderCrit));
}

/// M15 — JWE entirely outside profile (signing-only). JWE compact form
/// has 5 segments where JWS has 3, so the structural shape is enough to
/// reject. Engine fires before parsing so a JWE never reaches the
/// JWS-shaped header parser.
#[tokio::test]
async fn test_reject_jwe_payload() {
    // Fake JWE: 5 segments. Content irrelevant — only the segment count
    // matters at this stage.
    let jwe = "header.encrypted_key.iv.ciphertext.tag";
    let result = verify(jwe, &cfg(), &KeySet::new()).await;
    assert_eq!(result, Err(AuthError::JwePayload));
}

/// M16 — RFC 8725 §3.6: header parameter allow-list = {typ, alg, kid}.
/// Any other key — registered JOSE params the profile chose not to
/// support, or attacker-injected extras — gets rejected with
/// `HeaderExtraParam`. Specific checks (M07-M14) fire first, so audit
/// logs carry the precise variant when one applies.
#[tokio::test]
async fn test_reject_extra_header_params() {
    let token = forge_with_header(
        &json!({ "alg": "EdDSA", "typ": "at+jwt", "kid": "k4.pid.x", "x_attacker_marker": 1 }),
        &default_payload(),
    );
    let result = verify(&token, &cfg(), &KeySet::new()).await;
    assert_eq!(result, Err(AuthError::HeaderExtraParam));
}

/// M16a — RFC 7797: `b64: false` enables an unencoded payload form. The
/// profile requires base64url-encoded payloads; `b64=false` would break
/// the signature input construction the engine assumes. Engine maps to
/// `HeaderB64False` so audit logs distinguish "tried RFC 7797" from a
/// generic extra-param probe (M16).
#[tokio::test]
async fn test_reject_b64_false_header() {
    let token = forge_with_header(
        &json!({ "alg": "EdDSA", "typ": "at+jwt", "b64": false }),
        &default_payload(),
    );
    let result = verify(&token, &cfg(), &KeySet::new()).await;
    assert_eq!(result, Err(AuthError::HeaderB64False));
}

/// M14 — RFC 8725 §3.13: a `cty` (content type) of `JWT` or `JOSE`
/// declares the payload is itself a JWT/JWS. The profile is signing-only
/// and single-layer; nested signatures expand the audit surface and have
/// historically carried downgrade attacks. Both `JWT` and `JOSE` (and
/// case variants) trip the rejection.
#[tokio::test]
async fn test_reject_nested_jws() {
    for cty in ["JWT", "jwt", "JOSE", "jose"] {
        let token = forge_with_header(
            &json!({ "alg": "EdDSA", "typ": "at+jwt", "cty": cty }),
            &default_payload(),
        );
        let result = verify(&token, &cfg(), &KeySet::new()).await;
        assert_eq!(result, Err(AuthError::NestedJws), "cty={cty}");
    }
}

/// M13 + M13a — RFC 9068 §2.1 + RFC 8725 §3.11: access tokens MUST carry
/// `typ: "at+jwt"` (strict — even the legacy `"JWT"` is rejected). The
/// strict pin is what defends against a receiver accepting an id_token
/// where it expected an access token, and vice versa.
#[tokio::test]
async fn test_reject_arbitrary_typ() {
    for bad_typ in ["JWT", "jwt", "application/jwt", "id_token+jwt", ""] {
        let token = forge_with_header(
            &json!({ "alg": "EdDSA", "typ": bad_typ }),
            &default_payload(),
        );
        let result = verify(&token, &cfg(), &KeySet::new()).await;
        assert_eq!(result, Err(AuthError::TypMismatch), "typ={bad_typ:?}");
    }

    // Absent typ also rejected.
    let no_typ = forge_with_header(&json!({ "alg": "EdDSA" }), &default_payload());
    assert_eq!(
        verify(&no_typ, &cfg(), &KeySet::new()).await,
        Err(AuthError::TypMismatch),
    );
}

/// M13a — explicit subcase of M13: pinning `typ` to `at+jwt` is the
/// receiver-side defense against `id_token` substitution at an
/// access-token endpoint. Tested separately so a future change to
/// id-token typ ("id_token+jwt") doesn't accidentally break the
/// regression guard.
#[tokio::test]
async fn test_reject_id_token_at_access_endpoint() {
    let token = forge_with_header(
        &json!({ "alg": "EdDSA", "typ": "id_token+jwt" }),
        &default_payload(),
    );
    let result = verify(&token, &cfg(), &KeySet::new()).await;
    assert_eq!(result, Err(AuthError::TypMismatch));
}

/// M12 — RFC 8725 §3.6 + RFC 7517: the verifier resolves `kid` against a
/// server-pinned set, never a network lookup (M07/M08 already block URL
/// loaders). Missing kid and unknown kid both yield `KidUnknown` — an
/// attacker probing without `kid` and one guessing a wrong `kid` look
/// the same to the audit log.
#[tokio::test]
async fn test_reject_unknown_kid() {
    // (a) kid absent
    let no_kid = forge_with_header(
        &json!({ "alg": "EdDSA", "typ": "at+jwt" }),
        &default_payload(),
    );
    assert_eq!(
        verify(&no_kid, &cfg(), &KeySet::new()).await,
        Err(AuthError::KidUnknown),
    );

    // (b) kid present but not pinned
    let unknown_kid = forge_with_header(
        &json!({ "alg": "EdDSA", "typ": "at+jwt", "kid": "k4.pid.attacker" }),
        &default_payload(),
    );
    assert_eq!(
        verify(&unknown_kid, &cfg(), &KeySet::new()).await,
        Err(AuthError::KidUnknown),
    );
}

// M06 — RFC 8725 §3.2 ("use the algorithm sent by the application, not the
// one sent in the header"). Phase 7 §6.8: enforced *structurally*. The
// sealed `pub enum Algorithm { EdDSA }` makes a `cfg.algorithms` whitelist
// without EdDSA unconstructable — the misconfiguration this test guarded
// against cannot be expressed in the type system. The previous test
// (`test_alg_pinned_ignores_header`) constructed `with_algorithms(
// vec![Algorithm::ES256])` and asserted the EdDSA-headered token was
// rejected; with sealed `Algorithm`, the test body itself fails to
// compile. STANDARDS_JWT_DETAILS_MITIGATION §B M06 row reflects the
// `[~] (structural)` status.
