//! Regression test for the single-job read authorization bypass.
//!
//! The single-job read endpoints (`/jobs_u/get`, `/completed/get`,
//! `/completed/get_result`, `/get_args`, `/get_logs`, `/getupdate`, ...) fetch a
//! job through the root DB handle, filtered only by job id + workspace. That is
//! required for the unauthenticated approval / public-trigger / anonymous-job
//! flows, but for a *logged-in* user it meant any workspace member — including a
//! plain viewer with no ACL on the runnable — could read another user's job
//! args/result/logs simply by obtaining the job UUID, even though the same job is
//! hidden from them in `jobs/list` (RLS-filtered) and the underlying script
//! returns 404.
//!
//! The fix (`require_job_read_access`) gates the authenticated case: a caller may
//! read a job they created (covers app components / webhooks / their own runs)
//! or one visible to them under the same RLS as `jobs/list` (admins bypass);
//! otherwise 404. Unauthenticated access is unchanged (anonymous jobs only).
//!
//! This test pins down, against the `jobs_read_auth` fixture:
//!   - a viewer is denied the victim job's full record / result / result_maybe /
//!     args / logs / live update by UUID, and the secret never appears in the
//!     body (the core fix; pre-fix these returned 200 with the secret),
//!   - the job's owner and an admin can still read it (no over-blocking),
//!   - the "app component" affordance survives: a viewer who *launched* a job
//!     (created_by) running as someone else's identity can still read its result,
//!   - unauthenticated behavior is unchanged: anonymous jobs readable, the
//!     non-anonymous victim job rejected.

use sqlx::{Pool, Postgres};
use windmill_test_utils::*;

const VICTIM: &str = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
const APP_JOB: &str = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
const ANON_JOB: &str = "cccccccc-cccc-cccc-cccc-cccccccccccc";
const FLOW_JOB: &str = "dddddddd-dddd-dddd-dddd-dddddddddddd";
const STEP_JOB: &str = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee";
// Deep nesting: top (not visible) -> mid (visible via folder) -> deep leaf.
const TOP_SECRET_FLOW: &str = "ffffffff-ffff-ffff-ffff-ffffffffffff";
const DEEP_LEAF_JOB: &str = "88888888-8888-8888-8888-888888888888";
// A queued/running job (no completed row) owned by test-user-2.
const RUNNING_JOB: &str = "77777777-7777-7777-7777-777777777777";

// Secrets that must never leak to an unauthorized viewer.
const RESULT_SECRET: &str = "RESULT_SECRET";
const ARGS_SECRET: &str = "LEAK_TEST_ARGS";
const LOGS_SECRET: &str = "LEAK_TEST_LOGS";

fn client() -> reqwest::Client {
    reqwest::Client::new()
}

async fn get(base: &str, path: &str, token: Option<&str>) -> (reqwest::StatusCode, String) {
    let mut req = client().get(format!("{base}/{path}"));
    if let Some(token) = token {
        req = req.header("Authorization", format!("Bearer {token}"));
    }
    let resp = req.send().await.expect("request");
    let status = resp.status();
    let body = resp.text().await.expect("body");
    (status, body)
}

#[sqlx::test(fixtures("base", "jobs_read_auth"))]
async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Result<()> {
    initialize_tracing().await;

    let server = ApiServer::start(db.clone()).await?;
    let port = server.addr.port();
    let base = format!("http://localhost:{port}/api/w/test-workspace/jobs_u");
    // result_by_id / get_otel_traces live on the authed `/jobs` service, not `/jobs_u`.
    let authed_base = format!("http://localhost:{port}/api/w/test-workspace/jobs");

    // The endpoints that return the victim job's sensitive data by UUID.
    let endpoints = [
        ("get", format!("get/{VICTIM}")),
        ("completed/get", format!("completed/get/{VICTIM}")),
        (
            "completed/get_result",
            format!("completed/get_result/{VICTIM}"),
        ),
        (
            "completed/get_result_maybe",
            format!("completed/get_result_maybe/{VICTIM}"),
        ),
        ("get_args", format!("get_args/{VICTIM}")),
        ("get_logs", format!("get_logs/{VICTIM}")),
        (
            "get_completed_logs_tail",
            format!("get_completed_logs_tail/{VICTIM}"),
        ),
        ("get_flow_all_logs", format!("get_flow_all_logs/{VICTIM}")),
        (
            "completed/get_timing",
            format!("completed/get_timing/{VICTIM}"),
        ),
        ("getupdate", format!("getupdate/{VICTIM}?only_result=true")),
    ];

    // ---- CORE REGRESSION: the viewer (test-user-3) is denied on every endpoint
    //      and no secret ever appears in the body. Pre-fix these returned 200
    //      and leaked the secret.
    for (name, path) in &endpoints {
        let (status, body) = get(&base, path, Some("SECRET_TOKEN_3")).await;
        assert_eq!(
            status,
            reqwest::StatusCode::FORBIDDEN,
            "viewer must get 403 on {name} (got {status}): {body}"
        );
        for secret in [RESULT_SECRET, ARGS_SECRET, LOGS_SECRET] {
            assert!(
                !body.contains(secret),
                "viewer response for {name} leaked `{secret}`: {body}"
            );
        }
    }

    // The 403 for an existing-but-forbidden job carries actionable guidance
    // (request a share link), distinguishing it from a plain not-found.
    let (status, body) = get(
        &base,
        &format!("completed/get_result/{VICTIM}"),
        Some("SECRET_TOKEN_3"),
    )
    .await;
    assert_eq!(status, reqwest::StatusCode::FORBIDDEN);
    assert!(
        body.to_lowercase().contains("share"),
        "403 body should guide the user to request a share link: {body}"
    );

    // A genuinely non-existent job is a 404, not a 403 — existence is only disclosed
    // for jobs that actually exist in the workspace.
    let missing = "00000000-0000-4000-8000-000000000000";
    let (status, _) = get(
        &base,
        &format!("completed/get_result/{missing}"),
        Some("SECRET_TOKEN_3"),
    )
    .await;
    assert_eq!(
        status,
        reqwest::StatusCode::NOT_FOUND,
        "a non-existent job must be 404, not 403 (got {status})"
    );

    // ---- NO OVER-BLOCKING: the job's owner (test-user-2) can read its result.
    let (status, body) = get(
        &base,
        &format!("completed/get_result/{VICTIM}"),
        Some("SECRET_TOKEN_2"),
    )
    .await;
    assert!(
        status.is_success(),
        "owner must still read their own job result (got {status}): {body}"
    );
    assert!(
        body.contains(RESULT_SECRET),
        "owner result must contain the value: {body}"
    );

    // ---- ADMIN BYPASS: an admin (test-user) can read any job in the workspace.
    let (status, body) = get(
        &base,
        &format!("completed/get_result/{VICTIM}"),
        Some("SECRET_TOKEN"),
    )
    .await;
    assert!(
        status.is_success(),
        "admin must read any job (got {status}): {body}"
    );
    assert!(body.contains(RESULT_SECRET), "admin result body: {body}");

    // ---- APP AFFORDANCE: a viewer who LAUNCHED a job (created_by = viewer) that
    //      runs as another identity (permissioned_as = test-user-2,
    //      visible_to_owner = false) can still read its result. This is the app
    //      component-polling path; the fix must not break it.
    let (status, body) = get(
        &base,
        &format!("completed/get_result/{APP_JOB}"),
        Some("SECRET_TOKEN_3"),
    )
    .await;
    assert!(
        status.is_success(),
        "launcher must read a job they created even without ACL on the runnable (got {status}): {body}"
    );
    assert!(
        body.contains("visible_to_launcher"),
        "launcher should get the result they polled: {body}"
    );

    // ---- AUTHED `/jobs` endpoints in the same class: result_by_id (flow node
    //      result) and get_otel_traces (job telemetry). The viewer must be denied
    //      the victim by UUID. The auth gate runs before result/trace resolution,
    //      so 404 here is the gate, not incidental resolution failure.
    let (status, body) = get(
        &authed_base,
        &format!("result_by_id/{VICTIM}/somenode"),
        Some("SECRET_TOKEN_3"),
    )
    .await;
    assert_eq!(
        status,
        reqwest::StatusCode::FORBIDDEN,
        "viewer must get 403 on result_by_id (got {status}): {body}"
    );
    assert!(!body.contains(RESULT_SECRET), "result_by_id leaked: {body}");

    let (status, body) = get(
        &authed_base,
        &format!("get_otel_traces/{VICTIM}"),
        Some("SECRET_TOKEN_3"),
    )
    .await;
    assert_eq!(
        status,
        reqwest::StatusCode::FORBIDDEN,
        "viewer must get 403 on get_otel_traces (got {status}): {body}"
    );

    // ---- FLOW VISIBILITY INHERITANCE: test-user-3 has folder ACL on the flow
    //      `f/shared/flow1` (run by test-user-2) but did NOT launch it, and has no
    //      ACL on the step's inner runnable `u/test-user-2/inner_secret`. They must
    //      still be able to (a) read the flow they can see, and (b) inspect its
    //      step result — visibility is inherited from the flow root. A naive
    //      "same as list" gate would 404 the step and break the flow-run UI.
    let (status, body) = get(
        &base,
        &format!("completed/get_result/{FLOW_JOB}"),
        Some("SECRET_TOKEN_3"),
    )
    .await;
    assert!(
        status.is_success(),
        "viewer with folder ACL must read the flow they can see (got {status}): {body}"
    );
    let (status, body) = get(
        &base,
        &format!("completed/get_result/{STEP_JOB}"),
        Some("SECRET_TOKEN_3"),
    )
    .await;
    assert!(
        status.is_success(),
        "viewer must inspect a step of a flow they can see, even without ACL on the step's runnable (got {status}): {body}"
    );
    assert!(
        body.contains("STEP_RESULT_INHERITED"),
        "step result should be returned via flow-root inheritance: {body}"
    );

    // ---- DEEP NESTING / MIDDLE-LAYER VISIBILITY: the deep leaf's root_job is the
    //      top flow (NOT visible to test-user-3), but an intermediate sub-flow
    //      (f/shared/mid) IS visible. Reading the leaf must succeed via that middle
    //      ancestor — i.e. the full parent chain is walked, not just [self, root].
    let (status, body) = get(
        &base,
        &format!("completed/get_result/{DEEP_LEAF_JOB}"),
        Some("SECRET_TOKEN_3"),
    )
    .await;
    assert!(
        status.is_success(),
        "deep leaf must be readable via a visible intermediate sub-flow (got {status}): {body}"
    );
    assert!(
        body.contains("DEEP_STEP_INHERITED"),
        "deep leaf result should be returned via mid-ancestor visibility: {body}"
    );
    // ...but the top flow itself, in a folder the viewer cannot read, stays denied.
    let (status, body) = get(
        &base,
        &format!("completed/get_result/{TOP_SECRET_FLOW}"),
        Some("SECRET_TOKEN_3"),
    )
    .await;
    assert_eq!(
        status,
        reqwest::StatusCode::FORBIDDEN,
        "top flow in an unreadable folder must stay denied (got {status}): {body}"
    );

    // ---- UNAUTHENTICATED, unchanged: an anonymous-created job is readable
    //      without a token (public trigger / public app result polling).
    let (status, body) = get(&base, &format!("completed/get_result/{ANON_JOB}"), None).await;
    assert!(
        status.is_success(),
        "anonymous job must remain readable unauthenticated (got {status}): {body}"
    );

    // ---- UNAUTHENTICATED, unchanged: the non-anonymous victim job is rejected
    //      for an unauthenticated caller (400, the pre-existing guard).
    let (status, body) = get(&base, &format!("completed/get_result/{VICTIM}"), None).await;
    assert_eq!(
        status,
        reqwest::StatusCode::BAD_REQUEST,
        "unauthenticated access to a non-anonymous job must stay rejected (got {status}): {body}"
    );
    assert!(
        !body.contains(RESULT_SECRET),
        "unauth body must not leak: {body}"
    );

    // ---- SHARE READ LINK (view_token) ----
    // The owner (test-user-2) mints a share token for the victim job.
    let (status, mint_body) = get(
        &authed_base,
        &format!("job_view_token/{VICTIM}"),
        Some("SECRET_TOKEN_2"),
    )
    .await;
    assert!(
        status.is_success(),
        "owner must be able to mint a share token (got {status}): {mint_body}"
    );
    let token = mint_body.trim().trim_matches('"').to_string();
    assert!(
        token.starts_with(VICTIM),
        "token must encode the job id: {token}"
    );

    // The viewer (no ACL) can now read the victim job via the share link.
    let (status, body) = get(
        &base,
        &format!("completed/get_result/{VICTIM}?view_token={token}"),
        Some("SECRET_TOKEN_3"),
    )
    .await;
    assert!(
        status.is_success(),
        "view_token must grant the viewer read of the shared job (got {status}): {body}"
    );
    assert!(
        body.contains(RESULT_SECRET),
        "shared job result must be returned with a valid view_token: {body}"
    );
    // ...and its args/logs too (whole detail page).
    let (status, _) = get(
        &base,
        &format!("get_args/{VICTIM}?view_token={token}"),
        Some("SECRET_TOKEN_3"),
    )
    .await;
    assert!(
        status.is_success(),
        "view_token must also grant args (got {status})"
    );

    // The token is scoped: it does NOT authorize an unrelated job.
    let (status, _) = get(
        &base,
        &format!("completed/get_result/{ANON_JOB}?view_token={token}"),
        Some("SECRET_TOKEN_3"),
    )
    .await;
    assert_eq!(
        status,
        reqwest::StatusCode::FORBIDDEN,
        "a victim-scoped token must not authorize a different job (got {status})"
    );

    // A garbage token is rejected (falls through to the normal 404).
    let (status, _) = get(
        &base,
        &format!("completed/get_result/{VICTIM}?view_token={VICTIM}.deadbeef"),
        Some("SECRET_TOKEN_3"),
    )
    .await;
    assert_eq!(
        status,
        reqwest::StatusCode::FORBIDDEN,
        "an invalid view_token must not grant access (got {status})"
    );

    // A share token authorizes the shared job's whole flow subtree: the owner mints
    // for the top secret flow, and the viewer can then read its deep leaf.
    let (status, mint_body) = get(
        &authed_base,
        &format!("job_view_token/{TOP_SECRET_FLOW}"),
        Some("SECRET_TOKEN_2"),
    )
    .await;
    assert!(
        status.is_success(),
        "owner mints token for top flow (got {status}): {mint_body}"
    );
    let top_token = mint_body.trim().trim_matches('"').to_string();
    let (status, body) = get(
        &base,
        &format!("completed/get_result/{DEEP_LEAF_JOB}?view_token={top_token}"),
        Some("SECRET_TOKEN_3"),
    )
    .await;
    assert!(
        status.is_success(),
        "a flow's share token must authorize its deep descendants (got {status}): {body}"
    );

    // A viewer who cannot read a job cannot mint a share token for it.
    let (status, _) = get(
        &authed_base,
        &format!("job_view_token/{TOP_SECRET_FLOW}"),
        Some("SECRET_TOKEN_3"),
    )
    .await;
    assert_eq!(
        status,
        reqwest::StatusCode::FORBIDDEN,
        "a non-reader must not be able to mint a share token (got {status})"
    );

    // ---- TAG-SCOPED token must not mint a token outside its allowed tags ----
    // SCOPED_DENO_TOKEN (test-user-2, scope `if_jobs:filter_tags:deno`) can read both
    // VICTIM (tag deno) and FLOW_JOB (tag flow) by RLS, but minting must honor the
    // tag scope: allowed for the deno job, denied for the flow job.
    let (status, body) = get(
        &authed_base,
        &format!("job_view_token/{VICTIM}"),
        Some("SCOPED_DENO_TOKEN"),
    )
    .await;
    assert!(
        status.is_success(),
        "tag-scoped token may mint for an in-scope (deno) job (got {status}): {body}"
    );
    let (status, _) = get(
        &authed_base,
        &format!("job_view_token/{FLOW_JOB}"),
        Some("SCOPED_DENO_TOKEN"),
    )
    .await;
    assert_eq!(
        status,
        reqwest::StatusCode::NOT_FOUND,
        "tag-scoped token must NOT mint for an out-of-scope (flow) job (got {status})"
    );

    // ---- USE side: a tag-scoped token must not use someone else's valid view_token
    //      to read an out-of-scope job, even via handlers that don't tag-filter their
    //      data query (result_by_id, get_otel_traces, get_flow_debug_info). ----
    // An unscoped owner mints a valid token for the flow (tag 'flow').
    let (status, mint_body) = get(
        &authed_base,
        &format!("job_view_token/{FLOW_JOB}"),
        Some("SECRET_TOKEN_2"),
    )
    .await;
    assert!(
        status.is_success(),
        "owner mints flow token (got {status}): {mint_body}"
    );
    let flow_token = mint_body.trim().trim_matches('"').to_string();

    // The deno-scoped token presents that valid flow token to the non-tag-filtered
    // endpoints — must still be denied (flow tag is out of its scope).
    for path in [
        format!("get_otel_traces/{FLOW_JOB}?view_token={flow_token}"),
        format!("result_by_id/{FLOW_JOB}/somenode?view_token={flow_token}"),
    ] {
        let (status, _) = get(&authed_base, &path, Some("SCOPED_DENO_TOKEN")).await;
        assert_eq!(
            status,
            reqwest::StatusCode::NOT_FOUND,
            "tag-scoped token must not use a view_token to read an out-of-scope job ({path}, got {status})"
        );
    }

    // ...but the deno-scoped token CAN use an in-scope (deno) view_token.
    let (status, body) = get(
        &base,
        &format!("completed/get_result/{VICTIM}?view_token={token}"),
        Some("SCOPED_DENO_TOKEN"),
    )
    .await;
    assert!(
        status.is_success(),
        "tag-scoped token may use a view_token for an in-scope (deno) job (got {status}): {body}"
    );

    // ---- get_result_maybe?get_started=true must authorize before disclosing the
    //      running-state of a queued (not-yet-completed) private job. ----
    // Viewer (no ACL) must be denied rather than told the job is started.
    let (status, body) = get(
        &base,
        &format!("completed/get_result_maybe/{RUNNING_JOB}?get_started=true"),
        Some("SECRET_TOKEN_3"),
    )
    .await;
    assert_eq!(
        status,
        reqwest::StatusCode::FORBIDDEN,
        "viewer must be denied the running-state of a private queued job (got {status}): {body}"
    );
    assert!(
        !body.contains("\"started\""),
        "denied response must not disclose started-state: {body}"
    );
    // The owner still gets the in-progress response.
    let (status, body) = get(
        &base,
        &format!("completed/get_result_maybe/{RUNNING_JOB}?get_started=true"),
        Some("SECRET_TOKEN_2"),
    )
    .await;
    assert!(
        status.is_success() && body.contains("\"started\":true"),
        "owner must see the running job as started (got {status}): {body}"
    );

    Ok(())
}
