# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0

"""Security regression tests for audit findings C-2, C-6, C-7, C-9, H-1, H-5."""

import pytest
from datetime import datetime, timedelta, UTC
from unittest.mock import AsyncMock, MagicMock

from gsap_broker.connectors.base import CAP_MUTATE, CAP_READ, ConnectorContext, ConnectorResult
from gsap_broker.connectors.intune import IntuneConnector, _validate_device_id
from gsap_broker.credentials.resolver import (
    BasculeCredential, KerberosCredential, OAuthCredential, SSHCertCredential,
)


# ── H-1: Credential repr does not leak secrets ──────────────────

def test_oauth_credential_repr_hides_token():
    """H-1: access_token must not appear in repr."""
    cred = OAuthCredential(
        target="target", expires_at=datetime.now(UTC) + timedelta(hours=1),
        access_token="super-secret-token-123",
    )
    r = repr(cred)
    assert "super-secret-token-123" not in r


def test_kerberos_credential_repr_hides_ticket():
    """H-1: ticket bytes must not appear in repr."""
    cred = KerberosCredential(
        target="target", expires_at=datetime.now(UTC) + timedelta(hours=1),
        ticket=b"SECRET_KERBEROS_TICKET_BYTES",
    )
    r = repr(cred)
    assert "SECRET_KERBEROS_TICKET_BYTES" not in r


def test_ssh_credential_repr_hides_key():
    """H-1: private_key must not appear in repr."""
    cred = SSHCertCredential(
        target="target", expires_at=datetime.now(UTC) + timedelta(hours=1),
        certificate="cert-data", private_key="PRIVATE-KEY-MATERIAL",
    )
    r = repr(cred)
    assert "PRIVATE-KEY-MATERIAL" not in r


# ── H-5: device_id path traversal rejected ──────────────────────

def test_device_id_path_traversal_rejected():
    """H-5: path traversal in device_id is rejected."""
    with pytest.raises(ValueError, match="UUID format"):
        _validate_device_id("../../users/admin")


def test_device_id_valid_uuid_accepted():
    """H-5: valid UUID device_id is accepted."""
    result = _validate_device_id("550e8400-e29b-41d4-a716-446655440000")
    assert result == "550e8400-e29b-41d4-a716-446655440000"


def test_device_id_empty_rejected():
    with pytest.raises(ValueError, match="device_id required"):
        _validate_device_id("")


def test_device_id_not_uuid_rejected():
    with pytest.raises(ValueError, match="UUID format"):
        _validate_device_id("not-a-uuid")


# ── C-6: capability_mask enforcement ─────────────────────────────

@pytest.mark.asyncio
async def test_read_only_ac_cannot_invoke_wipe():
    """C-6: READ-only AC must be denied for MUTATE operations."""
    mock_graph = MagicMock()
    mock_graph.tenant_id = "t"
    mock_graph.client_id = "c"
    connector = IntuneConnector(graph_client=mock_graph)

    # Verify the connector declares wipe as MUTATE
    assert connector.capability_for_operation("wipe_device") == CAP_MUTATE
    assert connector.capability_for_operation("remote_lock") == CAP_MUTATE
    assert connector.capability_for_operation("retire_device") == CAP_MUTATE

    # Verify READ operations are READ
    assert connector.capability_for_operation("list_devices") == CAP_READ
    assert connector.capability_for_operation("get_compliance") == CAP_READ


# ── C-9: delegation capability bounding ──────────────────────────

@pytest.mark.asyncio
async def test_delegation_capability_exceeding_delegator_rejected():
    """C-9: delegated capability cannot exceed delegator's."""
    from gsap_broker.delegations.lifecycle import DelegationManager, _capability_mask_for
    from gsap_broker.delegations.models import DelegationRequest, DelegationScope

    manager = DelegationManager()
    request = DelegationRequest(
        delegator_ac_id="test-ac",
        agent_type="claude-code",
        scope=DelegationScope(capability_ceiling="CAP_ADMIN"),
    )

    # Delegator has only READ capability (mask=1)
    with pytest.raises(ValueError, match="exceeds delegator"):
        await manager.create_delegation(
            request, delegator_did="did:web:test/p/alice",
            delegator_capability_mask=CAP_READ,
        )


# ── C-2: on_behalf_of gate ───────────────────────────────────────

@pytest.mark.asyncio
async def test_on_behalf_of_without_impersonate_role_rejected(mocker):
    """C-2: on_behalf_of without gsap:impersonate role is rejected."""
    from httpx import AsyncClient, ASGITransport
    from sqlmodel import SQLModel
    from sqlalchemy.ext.asyncio import create_async_engine
    from gsap_broker.app import app
    from gsap_broker import db as db_module
    from gsap_broker.drivers.base import AuthResult

    engine = create_async_engine("sqlite+aiosqlite:///./test_security.db")
    db_module.engine = engine
    async with engine.begin() as conn:
        await conn.run_sync(SQLModel.metadata.create_all)

    try:
        mocker.patch(
            "gsap_broker.drivers.keycloak.KeycloakDriver.authenticate",
            return_value=AuthResult(
                status=AuthResult.STATUS_AUTHORIZED,
                principal_did="did:web:test/p/alice",
                token_jti="jti",
            ),
        )

        async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
            resp = await client.post("/governance/authorize/", json={
                "playbook": "test",
                "corpus_entry_cid": "sha256:" + "a" * 64,
                "parameters_cid": "sha256:" + "b" * 64,
                "accord_template": "test",
                "driver_id": "keycloak",
                "on_behalf_of": "did:web:test/p/admin",
            })
            assert resp.status_code == 403
            assert "gsap:impersonate" in resp.json()["detail"]
    finally:
        async with engine.begin() as conn:
            await conn.run_sync(SQLModel.metadata.drop_all)
