context7-cli 0.4.2

Search library documentation from your terminal — zero runtime, bilingual (EN/PT), multi-key rotation
Documentation
//! Testes de integração para o storage XDG end-to-end.
//!
//! Todos os testes usam `tempfile::TempDir` como `CONTEXT7_HOME` para
//! garantir isolamento total do sistema de arquivos real do usuário.
//!
//! Todos os testes são marcados com `#[serial]` porque manipulam variáveis
//! de ambiente de processo (`CONTEXT7_HOME`, `HOME`) que são globais.
//! Sem serial, dois testes em paralelo podem interferir nas env vars um do outro.

use assert_cmd::Command;
use predicates::prelude::*;
use serial_test::serial;
use tempfile::TempDir;

// ── Helper ────────────────────────────────────────────────────────────────────

/// Cria um `Command` isolado com `CONTEXT7_HOME` apontando para `dir`.
///
/// NÃO define `CONTEXT7_LANG` nem `CONTEXT7_API_KEYS` como strings vazias —
/// isso faria o clap rejeitar os valores `""` contra os value_parsers definidos.
/// O isolamento de chaves é garantido pelo `CONTEXT7_HOME` temporário vazio.
#[allow(deprecated)] // cargo_bin depreciado no assert_cmd 2.1.0+ (build-dir custom); este projeto não usa build-dir customizado
fn cmd_xdg(dir: &TempDir) -> Command {
    let mut cmd = Command::cargo_bin("context7").unwrap();
    cmd.env_clear()
        .env("CONTEXT7_HOME", dir.path())
        .env("HOME", dir.path());
    cmd
}

// ── Ciclo completo: add → list → remove ───────────────────────────────────────

/// Testa o ciclo completo: adiciona, lista, remove via CONTEXT7_HOME isolado.
#[test]
#[serial]
fn testa_add_list_remove_ciclo_completo_via_xdg_home() {
    let dir = TempDir::new().unwrap();
    let chave = "ctx7sk-ciclo-completo-12345678901";

    // Passo 1: Adiciona
    cmd_xdg(&dir)
        .args(["keys", "add", chave])
        .assert()
        .success();

    // Passo 2: Lista — deve ter exatamente 1 chave
    cmd_xdg(&dir)
        .args(["keys", "list"])
        .assert()
        .success()
        .stdout(predicate::str::contains("[1]").or(predicate::str::contains("1 chave")));

    // Passo 3: Remove pelo índice 1
    cmd_xdg(&dir)
        .args(["keys", "remove", "1"])
        .assert()
        .success();

    // Passo 4: Lista novamente — deve estar vazia
    cmd_xdg(&dir)
        .args(["keys", "list"])
        .assert()
        .success()
        .stdout(
            predicate::str::contains("Nenhuma chave")
                .or(predicate::str::contains("No key"))
                .or(predicate::str::contains("0 chave")),
        );
}

// ── Import/Export roundtrip ────────────────────────────────────────────────────

/// Testa import de arquivo .env → export → compara valores originais.
#[test]
#[serial]
fn testa_import_export_roundtrip() {
    let dir = TempDir::new().unwrap();
    let chave1 = "ctx7sk-import-chave-aaa-1234567890";
    let chave2 = "ctx7sk-import-chave-bbb-1234567890";

    // Cria arquivo .env temporário
    let env_file = dir.path().join("test.env");
    std::fs::write(
        &env_file,
        format!("CONTEXT7_API={chave1}\nCONTEXT7_API={chave2}\n"),
    )
    .unwrap();

    // Importa
    cmd_xdg(&dir)
        .args(["keys", "import", env_file.to_str().unwrap()])
        .assert()
        .success();

    // Exporta e verifica que ambas as chaves estão no output
    cmd_xdg(&dir)
        .args(["keys", "export"])
        .assert()
        .success()
        .stdout(predicate::str::contains(format!("CONTEXT7_API={chave1}")))
        .stdout(predicate::str::contains(format!("CONTEXT7_API={chave2}")));
}

// ── Clear ─────────────────────────────────────────────────────────────────────

/// Testa que `keys clear --yes` remove todas as chaves.
#[test]
#[serial]
fn testa_clear_remove_todas_as_chaves() {
    let dir = TempDir::new().unwrap();

    // Adiciona 3 chaves
    for i in 1..=3 {
        cmd_xdg(&dir)
            .args([
                "keys",
                "add",
                &format!("ctx7sk-chave-clear-{i:02}-1234567890"),
            ])
            .assert()
            .success();
    }

    // Clear com --yes
    cmd_xdg(&dir)
        .args(["keys", "clear", "--yes"])
        .assert()
        .success();

    // Verifica que está vazio
    cmd_xdg(&dir)
        .args(["keys", "list"])
        .assert()
        .success()
        .stdout(
            predicate::str::contains("Nenhuma chave")
                .or(predicate::str::contains("No key"))
                .or(predicate::str::contains("0 chave")),
        );
}

// ── Path com XDG override ──────────────────────────────────────────────────────

/// Testa que `keys path` retorna caminho contendo o `CONTEXT7_HOME` override.
#[test]
#[serial]
fn testa_path_retorna_xdg_config_home_override() {
    let dir = TempDir::new().unwrap();
    let dir_str = dir.path().to_str().unwrap().to_owned();

    cmd_xdg(&dir)
        .args(["keys", "path"])
        .assert()
        .success()
        // O caminho deve conter o TempDir ou "context7" (nome do diretório XDG)
        .stdout(predicate::str::contains(&dir_str).or(predicate::str::contains("context7")));
}

// ── Deduplicação ──────────────────────────────────────────────────────────────

/// Adicionar a mesma chave duas vezes não deve duplicar no storage.
#[test]
#[serial]
fn testa_add_chaves_duplicadas_nao_acumula() {
    let dir = TempDir::new().unwrap();
    let chave = "ctx7sk-duplicada-12345678901234";

    // Adiciona duas vezes
    cmd_xdg(&dir)
        .args(["keys", "add", chave])
        .assert()
        .success();
    cmd_xdg(&dir)
        .args(["keys", "add", chave])
        .assert()
        .success();

    // Export deve conter exatamente 1 ocorrência
    let output = cmd_xdg(&dir).args(["keys", "export"]).output().unwrap();
    let stdout = String::from_utf8_lossy(&output.stdout);
    let ocorrencias = stdout
        .lines()
        .filter(|l| l.contains(&format!("CONTEXT7_API={chave}")))
        .count();
    assert_eq!(
        ocorrencias, 1,
        "chave duplicada não deve ser armazenada duas vezes"
    );
}

// ── Remove índice inválido ─────────────────────────────────────────────────────

/// `keys remove` com índice inválido não deve causar panic — erro controlado.
#[test]
#[serial]
fn testa_remove_indice_invalido_retorna_erro_controlado() {
    let dir = TempDir::new().unwrap();

    // Sem nenhuma chave, tenta remover índice 99
    let saida = cmd_xdg(&dir)
        .args(["keys", "remove", "99"])
        .output()
        .unwrap();

    let stderr = String::from_utf8_lossy(&saida.stderr);
    let stdout = String::from_utf8_lossy(&saida.stdout);
    let combinado = format!("{stdout}{stderr}");

    assert!(
        !combinado.contains("thread 'main' panicked"),
        "remove com índice inválido não deve panic: {combinado}"
    );
    assert!(
        !combinado.contains("index out of bounds"),
        "não deve vazar mensagem interna de bounds: {combinado}"
    );
}

// ── Múltiplas chaves com índices corretos ─────────────────────────────────────

/// Adiciona 3 chaves, remove a do meio (índice 2), verifica que as outras persistem.
#[test]
#[serial]
fn testa_remove_chave_do_meio_preserva_demais() {
    let dir = TempDir::new().unwrap();
    let chave1 = "ctx7sk-chave-primeira-1234567890";
    let chave2 = "ctx7sk-chave-segunda--1234567890";
    let chave3 = "ctx7sk-chave-terceira-1234567890";

    for chave in [chave1, chave2, chave3] {
        cmd_xdg(&dir)
            .args(["keys", "add", chave])
            .assert()
            .success();
    }

    // Remove o índice 2 (chave2)
    cmd_xdg(&dir)
        .args(["keys", "remove", "2"])
        .assert()
        .success();

    // Export deve conter chave1 e chave3, mas não chave2
    let output = cmd_xdg(&dir).args(["keys", "export"]).output().unwrap();
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains(chave1),
        "chave1 deve persistir após remover chave2"
    );
    assert!(!stdout.contains(chave2), "chave2 deve ter sido removida");
    assert!(
        stdout.contains(chave3),
        "chave3 deve persistir após remover chave2"
    );
}

// ── Permissões do arquivo de config ───────────────────────────────────────────

/// Em sistemas Unix, o arquivo config.toml deve ter permissões 600 após escrita.
#[test]
#[serial]
#[cfg(unix)]
fn testa_config_toml_tem_permissoes_600_em_unix() {
    use std::os::unix::fs::PermissionsExt;

    let dir = TempDir::new().unwrap();
    cmd_xdg(&dir)
        .args(["keys", "add", "ctx7sk-permissao-unix-12345678"])
        .assert()
        .success();

    // Busca o arquivo config.toml dentro do TempDir
    let config_path = dir.path().join("context7").join("config.toml");

    if config_path.exists() {
        let metadata = std::fs::metadata(&config_path).unwrap();
        let modo = metadata.permissions().mode();
        // Verifica que apenas o dono tem leitura e escrita (0o600)
        let bits_outros = modo & 0o077;
        assert_eq!(
            bits_outros, 0,
            "config.toml deve ter permissões 600, outros bits: {bits_outros:o}"
        );
    }
}

// ── Regression B3: exit code 1 em keys remove com índice inválido ─────────────

/// `keys remove 0` deve retornar exit code 1 (índice inválido — começa em 1).
#[test]
#[serial]
fn testa_keys_remove_indice_zero_retorna_exit_1() {
    let dir = TempDir::new().unwrap();

    cmd_xdg(&dir)
        .args(["keys", "add", "ctx7sk-b3-idx-zero-12345678901"])
        .assert()
        .success();

    cmd_xdg(&dir)
        .args(["keys", "remove", "0"])
        .assert()
        .failure();
}

/// `keys remove` com índice maior que o total deve retornar exit code 1.
#[test]
#[serial]
fn testa_keys_remove_indice_excede_total_retorna_exit_1() {
    let dir = TempDir::new().unwrap();

    cmd_xdg(&dir)
        .args(["keys", "add", "ctx7sk-b3-overflow-12345678901"])
        .assert()
        .success();

    // Há 1 chave, índice 99 é inválido
    cmd_xdg(&dir)
        .args(["keys", "remove", "99"])
        .assert()
        .failure();
}

/// `keys remove` com lista vazia deve retornar exit code 1.
#[test]
#[serial]
fn testa_keys_remove_lista_vazia_retorna_exit_1() {
    let dir = TempDir::new().unwrap();

    // Sem nenhuma chave adicionada
    cmd_xdg(&dir)
        .args(["keys", "remove", "1"])
        .assert()
        .failure();
}

// ── LOW-01: path traversal em CONTEXT7_HOME ───────────────────────────────────

/// `keys path` com CONTEXT7_HOME contendo `..` não deve retornar caminho com `..`.
/// Verifica que a proteção contra path traversal funciona em runtime via CLI.
#[test]
#[serial]
#[allow(deprecated)] // cargo_bin depreciado no assert_cmd 2.1.0+ (build-dir custom); este projeto não usa build-dir customizado
fn testa_keys_path_com_path_traversal_nao_expoe_caminho_pai() {
    let casos = ["../../../etc", "..", "/tmp/../etc"];

    for caso in &casos {
        let mut cmd = assert_cmd::Command::cargo_bin("context7").unwrap();
        let output = cmd
            .env_clear()
            .env("CONTEXT7_HOME", caso)
            .env("HOME", "/tmp")
            .args(["keys", "path"])
            .output()
            .unwrap();

        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);
        let combinado = format!("{stdout}{stderr}");

        // O caminho retornado não deve conter componente `..`
        assert!(
            !combinado.contains("/../"),
            "CONTEXT7_HOME='{caso}': caminho na saída não deve conter '/../': {combinado}"
        );
        assert!(
            !combinado.contains("/.."),
            "CONTEXT7_HOME='{caso}': caminho na saída não deve terminar com '/..': {combinado}"
        );
        // Não deve causar panic
        assert!(
            !combinado.contains("thread 'main' panicked"),
            "CONTEXT7_HOME='{caso}': não deve causar panic: {combinado}"
        );
    }
}