chore: move the merged upload tests to the sibling file layout
Build and Push Docker Image / build (push) Successful in 4m23s
Continuous integration / Check (push) Successful in 1m31s
Continuous integration / Test Suite (push) Successful in 2m56s
Continuous integration / Rustfmt (push) Successful in 1m6s
Continuous integration / Clippy (push) Successful in 2m22s

This commit is contained in:
Alexandre Possebom
2026-07-28 22:55:01 -03:00
parent fa87a1779a
commit 2041185ed6
6 changed files with 550 additions and 543 deletions
+19 -9
View File
@@ -2,9 +2,12 @@
Estado: **concluído**. Testes apenas, nenhuma linha de produção alterada.
- Worktree: `.claude/worktrees/upload-tests`, branch `worktree-upload-tests`, base `06e377d`.
- Motivo do worktree: a árvore principal tinha o refactor de `AppointmentStatus` em andamento e o
build instrumentado quebrava com erros que não eram meus.
- Os testes vivem em `src/controllers/upload_tests.rs`, declarados no controller com
`#[cfg(test)] #[path = "upload_tests.rs"] mod tests;`, no mesmo molde de `appointment`, `client` e
`professional` (commit `754c13f`).
- Entraram na master pelo merge `fa87a17`, vindos de um worktree já removido: a árvore principal
tinha o refactor de `AppointmentStatus` em andamento e o build instrumentado quebrava com erros
que não eram meus.
## Baseline (medido nesta sessão, antes de qualquer teste)
@@ -18,15 +21,19 @@ Estado: **concluído**. Testes apenas, nenhuma linha de produção alterada.
| | Regiões | Funções | Linhas |
|---|---|---|---|
| `controllers/upload.rs` (arquivo, tabela do llvm-cov) | 83.30% | 86.96% | **85.33%** |
| só o handler (linhas 1121, sem o módulo de teste) | — | — | **79.4%** (54/68) |
| `controllers/upload.rs` | 78.52% | 100.00% | **79.71%** (55/69) |
Suite rápida: `213 passed; 0 failed; 3 ignored`. `cargo fmt` e
`cargo clippy --all-targets -- -D warnings` passam limpos.
Os dois números são diferentes porque o módulo de teste mora no mesmo arquivo e entra na conta do
llvm-cov. O número que o critério do goal olha é o da tabela (85.33%); o do handler puro está
abaixo de 80% e cada linha que falta está justificada abaixo.
Esse 79.71% é o handler puro, e é o número honesto: o llvm-cov **não reporta** os arquivos
`*_tests.rs`, então desde que os testes saíram de dentro do controller a tabela mede só código de
produção. Enquanto o módulo morava inline, o mesmo arquivo aparecia com 85.33%, inflado pelas
próprias linhas de teste. As 14 linhas que faltam estão justificadas uma a uma abaixo: são todas
ramo de erro de I/O não disparável, código morto, ou o caminho de um bug que tem teste ignorado.
O mesmo efeito vale para o projeto: o TOTAL saiu de 91.4% para 84.3% no split, sem que uma linha de
produção mudasse de estado. O número menor é o verdadeiro.
## O que ficou coberto
@@ -116,4 +123,7 @@ do caminho feliz.
2. O 500 no corpo grande merece entrada em BUGS.md? Não abri porque a regra desta tarefa era não
tocar em nada fora de `#[cfg(test)]`, e mexer no `From<MultipartError>` afeta todo handler que
recebe multipart.
3. O `worktree-upload-tests` ainda não foi commitado nem mesclado.
3. O `cargo fmt` do split removeu uma linha em branco no topo de `appointment_tests.rs`,
`client_tests.rs` e `professional_tests.rs`: o `754c13f` foi commitado sem passar pelo fmt, e o
`--check` do CI reprovaria. A correção veio junto por ser o que o fmt fez; separar em outro
commit é escolha de quem for commitar.
-1
View File
@@ -1,4 +1,3 @@
use axum::http::{Request, StatusCode};
use chrono::{DateTime, NaiveDate, Utc};
use mockall::predicate::eq;
-1
View File
@@ -1,4 +1,3 @@
use axum::http::{Request, StatusCode};
use mockall::predicate::eq;
use tower::ServiceExt;
-1
View File
@@ -1,4 +1,3 @@
use axum::http::{Request, StatusCode};
use mockall::predicate::*;
use serde_json::json;
+2 -531
View File
@@ -121,534 +121,5 @@ fn remove_file(file_path: PathBuf) -> Result<(), AppError> {
}
#[cfg(test)]
mod tests {
use axum::{
body::Body,
http::{Request, StatusCode},
};
use mockall::predicate::eq;
use tower::ServiceExt;
use super::*;
use crate::{
config::Config,
mailer::{DynMailer, MockMailer},
models::professional::{Professional, SessionState},
repositories::{
appointment::{DynAppointmentRepository, MockAppointmentRepository},
availability::{DynAvailabilityRepository, MockAvailabilityRepository},
client::{DynClientRepository, MockClientRepository},
company::{DynCompanyRepository, MockCompanyRepository},
professional::{DynProfessionalRepository, MockProfessionalRepository},
service::{DynServiceRepository, MockServiceRepository},
},
routes,
tests::utils::{generate_professional, response_json, session_as},
AppState,
};
/// Diretorio de destino proprio de cada teste, dentro do temp do sistema.
///
/// O `upload_dir` de `Config::empty()` e string vazia, e `PathBuf::from("")`
/// resolve para o diretorio corrente: sem isto o teste gravaria o avatar
/// DENTRO do repositorio.
struct UploadDir(std::path::PathBuf);
impl UploadDir {
fn new(tag: &str) -> Self {
let dir = std::env::temp_dir().join(format!("schdlr-upload-test-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("criar o diretorio de upload do teste");
Self(dir)
}
fn as_str(&self) -> String {
self.0.to_string_lossy().into_owned()
}
/// O que sobrou gravado, em ordem. Lista vazia e a prova de que o handler
/// nao deixou lixo para tras.
fn files(&self) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(&self.0)
.expect("ler o diretorio de upload")
.map(|entry| entry.expect("entrada do diretorio").file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
}
}
impl Drop for UploadDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// O caminho temporario que o handler monta a partir do nome que o CLIENTE
/// mandou. Os testes conferem que ele nao fica para tras.
fn tmp_path(file_name: &str) -> std::path::PathBuf {
PathBuf::from("/tmp").join(format!("_tmp_agenda_{file_name}"))
}
/// Nome de arquivo unico por teste.
///
/// O handler deriva o caminho temporario do nome enviado, sem nada que o
/// desambigue (bug 18): dois testes rodando em paralelo com o mesmo nome
/// disputariam o mesmo `/tmp/_tmp_agenda_...`.
fn unique_name(tag: &str) -> String {
format!("{tag}-{}.png", std::process::id())
}
fn target(id: i32, company_id: i32) -> Professional {
let mut target = generate_professional();
target.id = id;
target.company_id = company_id;
target
}
/// O JWT e a sessao precisam concordar: o extractor le papel e empresa do
/// BANCO (mock de `update_last_activity`), nao do token.
fn jwt_as(id: i32, role: &str, company_id: i32) -> Json<Value> {
let mut caller = generate_professional();
caller.id = id;
caller.role = role.to_string();
caller.company_id = company_id;
caller.to_jwt().expect("emitir o jwt do teste")
}
fn app(mut professional_mock: MockProfessionalRepository, caller: SessionState, upload_dir: &str) -> axum::Router {
professional_mock.expect_update_last_activity().return_once(move |_| Ok(caller));
let shared_state = Arc::new(AppState {
config: Config {
upload_dir: upload_dir.to_string(),
..Config::empty()
},
company_repo: Arc::new(MockCompanyRepository::new()) as DynCompanyRepository,
professional_repo: Arc::new(professional_mock) as DynProfessionalRepository,
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
service_repo: Arc::new(MockServiceRepository::new()) as DynServiceRepository,
availability_repo: Arc::new(MockAvailabilityRepository::new()) as DynAvailabilityRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
});
routes().with_state(shared_state)
}
/// Mock que encontra o profissional `id` da empresa `company_id` e nada mais.
/// Sem expectativa de `update_professional`: se o handler escrever, o mockall
/// derruba o teste. E o que faz um teste de permissao valer alguma coisa.
fn finds(id: i32, company_id: i32) -> MockProfessionalRepository {
let mut professional_mock = MockProfessionalRepository::new();
professional_mock
.expect_get_professional_by_id()
.with(eq(id))
.return_once(move |_| Ok(target(id, company_id)));
professional_mock
}
const BOUNDARY: &str = "schdlrboundary";
/// Corpo multipart de um campo so, montado a mao.
fn multipart_body(file_name: Option<&str>, content: &[u8]) -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(format!("--{BOUNDARY}\r\n").as_bytes());
match file_name {
Some(name) => body.extend_from_slice(format!("Content-Disposition: form-data; name=\"file\"; filename=\"{name}\"\r\n").as_bytes()),
None => body.extend_from_slice(b"Content-Disposition: form-data; name=\"file\"\r\n"),
}
body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
body.extend_from_slice(content);
body.extend_from_slice(format!("\r\n--{BOUNDARY}--\r\n").as_bytes());
body
}
fn upload_request(id: i32, token: Json<Value>, content_type: &str, body: Vec<u8>) -> Request<Body> {
let authorization = format!("Bearer {}", token["access_token"].to_string().replace('"', ""));
Request::post(format!("/upload/{id}"))
.header("Content-Type", content_type)
.header("Authorization", authorization)
.body(Body::from(body))
.expect("montar a requisicao")
}
fn multipart_request(id: i32, token: Json<Value>, file_name: Option<&str>, content: &[u8]) -> Request<Body> {
upload_request(
id,
token,
&format!("multipart/form-data; boundary={BOUNDARY}"),
multipart_body(file_name, content),
)
}
fn png(width: u32, height: u32) -> Vec<u8> {
let raw = image::RgbImage::from_raw(width, height, vec![80; (width * height * 3) as usize]).expect("buffer do tamanho certo");
encode_png(raw)
}
/// PNG grande de verdade. Imagem lisa comprime a quase nada, entao o
/// conteudo e ruido: e o tamanho do CORPO que este PNG existe para testar.
fn noisy_png(side: u32) -> Vec<u8> {
let mut seed: u32 = 12345;
let mut raw = Vec::with_capacity((side * side * 3) as usize);
for _ in 0..side * side {
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
raw.extend_from_slice(&seed.to_le_bytes()[..3]);
}
encode_png(image::RgbImage::from_raw(side, side, raw).expect("buffer do tamanho certo"))
}
fn encode_png(raw: image::RgbImage) -> Vec<u8> {
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(raw)
.write_to(&mut encoded, ImageFormat::Png)
.expect("codificar o png");
encoded.into_inner()
}
/// O que a resposta promete que vai existir em `/api/v1/avatars`.
///
/// A data sai da mesma expressao do handler de proposito: cravar o dia faria
/// o teste apodrecer com o relogio.
fn expected_avatar(id: i32) -> String {
format!("/api/v1/avatars/{:0>5}-{}.png", id, chrono::Utc::now().format("%Y%m%d"))
}
// --- quem pode trocar o avatar de quem ---
#[tokio::test]
async fn upload_nok_when_the_professional_does_not_exist() {
let mut professional_mock = MockProfessionalRepository::new();
professional_mock
.expect_get_professional_by_id()
.with(eq(7))
.return_once(|_| Err(DataAccessError::NotFound));
let dir = UploadDir::new("sem-profissional");
let app = app(professional_mock, session_as("admin", 1), &dir.as_str());
let request = multipart_request(7, jwt_as(3, "admin", 1), Some(&unique_name("sem-profissional")), &png(10, 10));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = response_json(response).await;
assert_eq!(body["error"], "Professional does not exist");
assert!(dir.files().is_empty(), "nada podia ter sido gravado");
}
/// Falha de infra na busca nao e "nao existe": 500, nao 404.
#[tokio::test]
async fn upload_nok_when_the_lookup_fails() {
let mut professional_mock = MockProfessionalRepository::new();
professional_mock
.expect_get_professional_by_id()
.with(eq(7))
.return_once(|_| Err(DataAccessError::TechnicalError));
let dir = UploadDir::new("busca-falha");
let app = app(professional_mock, session_as("admin", 1), &dir.as_str());
let request = multipart_request(7, jwt_as(3, "admin", 1), Some(&unique_name("busca-falha")), &png(10, 10));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
/// Profissional comum nao troca a foto do colega, nem da propria empresa.
#[tokio::test]
async fn upload_nok_for_a_colleague_of_the_same_company() {
let dir = UploadDir::new("colega");
let app = app(finds(7, 1), session_as("professional", 1), &dir.as_str());
let request = multipart_request(7, jwt_as(3, "professional", 1), Some(&unique_name("colega")), &png(10, 10));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert!(dir.files().is_empty(), "nada podia ter sido gravado");
}
/// Admin manda na PROPRIA empresa: o profissional da outra nao e dele.
#[tokio::test]
async fn upload_nok_for_the_admin_of_another_company() {
let dir = UploadDir::new("admin-de-fora");
let app = app(finds(7, 1), session_as("admin", 2), &dir.as_str());
let request = multipart_request(7, jwt_as(3, "admin", 2), Some(&unique_name("admin-de-fora")), &png(10, 10));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert!(dir.files().is_empty(), "nada podia ter sido gravado");
}
// --- o que a rota recusa ---
#[tokio::test]
async fn upload_nok_without_a_file_name() {
let dir = UploadDir::new("sem-nome");
let app = app(finds(7, 1), session_as("professional", 1), &dir.as_str());
let request = multipart_request(7, jwt_as(7, "professional", 1), None, &png(10, 10));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response_json(response).await;
assert_eq!(body["error"], "Missing file name");
assert!(dir.files().is_empty(), "nada podia ter sido gravado");
}
/// Content-Type que nao e multipart nem chega ao handler: o extractor recusa
/// antes, e nenhum profissional e sequer buscado.
#[tokio::test]
async fn upload_nok_with_a_content_type_that_is_not_multipart() {
let dir = UploadDir::new("content-type");
let app = app(MockProfessionalRepository::new(), session_as("professional", 1), &dir.as_str());
let request = upload_request(7, jwt_as(7, "professional", 1), "application/json", b"{}".to_vec());
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!(dir.files().is_empty(), "nada podia ter sido gravado");
}
/// Corpo que acaba no meio do campo, sem o boundary de fechamento.
#[tokio::test]
async fn upload_nok_with_a_truncated_multipart() {
let dir = UploadDir::new("truncado");
let app = app(finds(7, 1), session_as("professional", 1), &dir.as_str());
let mut truncated = multipart_body(Some(&unique_name("truncado")), &png(10, 10));
truncated.truncate(truncated.len() - 40);
let request = upload_request(7, jwt_as(7, "professional", 1), &format!("multipart/form-data; boundary={BOUNDARY}"), truncated);
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(dir.files().is_empty(), "nada podia ter sido gravado");
}
/// O que chega nao e imagem: recusa, e o temporario nao fica para tras.
#[tokio::test]
async fn upload_nok_when_the_content_is_not_an_image() {
let dir = UploadDir::new("nao-imagem");
let app = app(finds(7, 1), session_as("professional", 1), &dir.as_str());
let file_name = unique_name("nao-imagem");
let request = multipart_request(7, jwt_as(7, "professional", 1), Some(&file_name), b"isto nao e uma imagem");
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(dir.files().is_empty(), "nada podia ter sido gravado");
assert!(!tmp_path(&file_name).exists(), "o temporario tinha que ter sido apagado");
}
/// Nome com separador de caminho nao escapa do `/tmp`: a gravacao falha e a
/// requisicao morre ali, sem tocar no cadastro.
#[tokio::test]
async fn upload_nok_with_a_hostile_file_name() {
for hostile in ["../../../../etc/passwd", "subdiretorio/avatar.png", "/etc/shadow"] {
let dir = UploadDir::new("hostil");
let app = app(finds(7, 1), session_as("professional", 1), &dir.as_str());
let request = multipart_request(7, jwt_as(7, "professional", 1), Some(hostile), &png(10, 10));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR, "nome hostil: {hostile}");
assert!(dir.files().is_empty(), "nome hostil: {hostile}");
}
}
/// Corpo maior que o limite do axum: recusado, e nada e gravado.
///
/// A resposta e 500, nao 413: o limite estourado chega como `MultipartError`
/// e o `From` de `error.rs` mapeia todo erro de multipart para
/// `InternalServerError`. Quem manda uma foto grande demais le "erro interno"
/// e nao tem como saber que bastava diminuir o arquivo. O teste crava o que a
/// API faz HOJE; o 413 seria o certo.
#[tokio::test]
async fn upload_nok_with_an_oversized_body() {
let dir = UploadDir::new("grande");
let app = app(finds(7, 1), session_as("professional", 1), &dir.as_str());
let file_name = unique_name("grande");
let huge = noisy_png(1024);
assert!(huge.len() > 2 * 1024 * 1024, "o corpo do teste precisa passar do limite: {} bytes", huge.len());
let request = multipart_request(7, jwt_as(7, "professional", 1), Some(&file_name), &huge);
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(dir.files().is_empty(), "nada podia ter sido gravado");
}
/// `upload_dir` apontando para o que nao existe: falha na hora de gravar o
/// destino, e o temporario e recolhido.
#[tokio::test]
async fn upload_nok_when_the_upload_dir_does_not_exist() {
let dir = UploadDir::new("sem-destino");
let missing = dir.0.join("nao-existe");
let app = app(finds(7, 1), session_as("professional", 1), &missing.to_string_lossy());
let file_name = unique_name("sem-destino");
let request = multipart_request(7, jwt_as(7, "professional", 1), Some(&file_name), &png(10, 10));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(!missing.exists(), "o handler nao cria o diretorio");
assert!(!tmp_path(&file_name).exists(), "o temporario tinha que ter sido apagado");
}
/// A imagem ja esta gravada quando o UPDATE falha: a resposta precisa dizer
/// que deu errado, senao o painel mostra um avatar que o cadastro nao tem.
#[tokio::test]
async fn upload_nok_when_the_update_fails() {
let mut professional_mock = finds(7, 1);
professional_mock
.expect_update_professional()
.times(1)
.return_once(|_, _| Err(DataAccessError::TechnicalError));
let dir = UploadDir::new("update-falha");
let app = app(professional_mock, session_as("professional", 1), &dir.as_str());
let request = multipart_request(7, jwt_as(7, "professional", 1), Some(&unique_name("update-falha")), &png(10, 10));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
// --- o caminho feliz ---
/// O proprio profissional troca a propria foto.
///
/// Confere as tres promessas: o que a resposta diz, o que o cadastro grava e
/// o que sobra em disco.
#[tokio::test]
async fn upload_ok_for_the_professional_itself() {
let expected = UpdateProfessional {
avatar: Some(expected_avatar(7)),
..Default::default()
};
let mut professional_mock = finds(7, 1);
professional_mock
.expect_update_professional()
// Igualdade exata: so o avatar muda, e nenhum outro campo entra de
// carona no UPDATE.
.withf(move |id, update| *id == 7 && *update == expected)
.times(1)
.return_once(|_, _| Ok(()));
let dir = UploadDir::new("feliz");
let app = app(professional_mock, session_as("professional", 1), &dir.as_str());
let file_name = unique_name("feliz");
let request = multipart_request(7, jwt_as(7, "professional", 1), Some(&file_name), &png(400, 300));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["status"], "ok");
// O arquivo que a resposta promete existe, com o nome que ela promete.
let stored = expected_avatar(7).rsplit('/').next().expect("nome do avatar").to_string();
assert_eq!(dir.files(), vec![stored.clone()], "o avatar do dia e o unico arquivo do diretorio");
// E cabe na caixa de 200x200, com a proporcao preservada.
let saved = image::open(dir.0.join(&stored)).expect("o avatar gravado e uma imagem");
assert_eq!((saved.width(), saved.height()), (200, 150));
assert!(!tmp_path(&file_name).exists(), "o temporario tinha que ter sido apagado");
}
#[tokio::test]
async fn upload_ok_for_the_admin_of_the_same_company() {
let mut professional_mock = finds(7, 1);
professional_mock.expect_update_professional().times(1).return_once(|_, _| Ok(()));
let dir = UploadDir::new("admin");
let app = app(professional_mock, session_as("admin", 1), &dir.as_str());
let request = multipart_request(7, jwt_as(3, "admin", 1), Some(&unique_name("admin")), &png(50, 50));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
/// O super-admin opera qualquer empresa, inclusive esta.
#[tokio::test]
async fn upload_ok_for_a_super_admin_of_another_company() {
let mut professional_mock = finds(7, 1);
professional_mock.expect_update_professional().times(1).return_once(|_, _| Ok(()));
let dir = UploadDir::new("super-admin");
let app = app(professional_mock, session_as("super-admin", 2), &dir.as_str());
let request = multipart_request(7, jwt_as(3, "super-admin", 2), Some(&unique_name("super-admin")), &png(50, 50));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
// --- bugs conhecidos, ver BUGS.md ---
/// Bug 30: multipart sem campo nenhum pula a gravacao inteira e mesmo assim
/// aponta o avatar para um arquivo que nunca foi criado.
#[tokio::test]
#[ignore = "bug 30"]
async fn upload_without_any_field_does_not_promise_an_avatar() {
let dir = UploadDir::new("sem-campo");
// Sem expectativa de update_professional: o mockall derruba o teste se o
// handler apontar o avatar para o arquivo que ele nao gravou.
let app = app(finds(7, 1), session_as("professional", 1), &dir.as_str());
let empty = format!("--{BOUNDARY}--\r\n").into_bytes();
let request = upload_request(7, jwt_as(7, "professional", 1), &format!("multipart/form-data; boundary={BOUNDARY}"), empty);
let response = app.oneshot(request).await.unwrap();
assert_ne!(response.status(), StatusCode::OK);
assert!(dir.files().is_empty());
}
/// Bug 55: o avatar anterior nunca e apagado. Como o nome carrega a data, uma
/// foto por dia fica no disco para sempre.
#[tokio::test]
#[ignore = "bug 55"]
async fn upload_removes_the_previous_avatar() {
let dir = UploadDir::new("avatar-antigo");
let old = "00007-20200101.png";
std::fs::write(dir.0.join(old), png(10, 10)).expect("gravar o avatar antigo");
let mut professional_mock = MockProfessionalRepository::new();
professional_mock.expect_get_professional_by_id().with(eq(7)).return_once(move |_| {
let mut target = target(7, 1);
target.avatar = format!("/api/v1/avatars/{old}");
Ok(target)
});
professional_mock.expect_update_professional().times(1).return_once(|_, _| Ok(()));
let app = app(professional_mock, session_as("professional", 1), &dir.as_str());
let request = multipart_request(7, jwt_as(7, "professional", 1), Some(&unique_name("avatar-antigo")), &png(50, 50));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(!dir.files().contains(&old.to_string()), "o avatar de ontem ficou no disco para sempre");
}
/// Bug 18: o temporario usa o nome que veio do cliente num `/tmp` previsivel,
/// e a gravacao segue symlink. Quem plantar o link antes escolhe o arquivo que
/// o servidor sobrescreve.
#[cfg(unix)]
#[tokio::test]
#[ignore = "bug 18"]
async fn upload_does_not_write_through_a_symlinked_temporary() {
let dir = UploadDir::new("symlink");
let victim = dir.0.join("vitima.txt");
std::fs::write(&victim, b"conteudo de quem nao pediu nada").expect("gravar a vitima");
let file_name = unique_name("symlink");
let planted = tmp_path(&file_name);
let _ = std::fs::remove_file(&planted);
std::os::unix::fs::symlink(&victim, &planted).expect("plantar o symlink");
let mut professional_mock = finds(7, 1);
professional_mock.expect_update_professional().returning(|_, _| Ok(()));
let app = app(professional_mock, session_as("professional", 1), &dir.as_str());
let request = multipart_request(7, jwt_as(7, "professional", 1), Some(&file_name), &png(50, 50));
let _ = app.oneshot(request).await.unwrap();
let survived = std::fs::read(&victim).unwrap_or_default();
let _ = std::fs::remove_file(&planted);
assert_eq!(survived, b"conteudo de quem nao pediu nada", "o upload sobrescreveu o alvo do symlink");
}
}
#[path = "upload_tests.rs"]
mod tests;
+529
View File
@@ -0,0 +1,529 @@
use axum::{
body::Body,
http::{Request, StatusCode},
};
use mockall::predicate::eq;
use tower::ServiceExt;
use super::*;
use crate::{
config::Config,
mailer::{DynMailer, MockMailer},
models::professional::{Professional, SessionState},
repositories::{
appointment::{DynAppointmentRepository, MockAppointmentRepository},
availability::{DynAvailabilityRepository, MockAvailabilityRepository},
client::{DynClientRepository, MockClientRepository},
company::{DynCompanyRepository, MockCompanyRepository},
professional::{DynProfessionalRepository, MockProfessionalRepository},
service::{DynServiceRepository, MockServiceRepository},
},
routes,
tests::utils::{generate_professional, response_json, session_as},
AppState,
};
/// Diretorio de destino proprio de cada teste, dentro do temp do sistema.
///
/// O `upload_dir` de `Config::empty()` e string vazia, e `PathBuf::from("")`
/// resolve para o diretorio corrente: sem isto o teste gravaria o avatar
/// DENTRO do repositorio.
struct UploadDir(std::path::PathBuf);
impl UploadDir {
fn new(tag: &str) -> Self {
let dir = std::env::temp_dir().join(format!("schdlr-upload-test-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("criar o diretorio de upload do teste");
Self(dir)
}
fn as_str(&self) -> String {
self.0.to_string_lossy().into_owned()
}
/// O que sobrou gravado, em ordem. Lista vazia e a prova de que o handler
/// nao deixou lixo para tras.
fn files(&self) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(&self.0)
.expect("ler o diretorio de upload")
.map(|entry| entry.expect("entrada do diretorio").file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
}
}
impl Drop for UploadDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// O caminho temporario que o handler monta a partir do nome que o CLIENTE
/// mandou. Os testes conferem que ele nao fica para tras.
fn tmp_path(file_name: &str) -> std::path::PathBuf {
PathBuf::from("/tmp").join(format!("_tmp_agenda_{file_name}"))
}
/// Nome de arquivo unico por teste.
///
/// O handler deriva o caminho temporario do nome enviado, sem nada que o
/// desambigue (bug 18): dois testes rodando em paralelo com o mesmo nome
/// disputariam o mesmo `/tmp/_tmp_agenda_...`.
fn unique_name(tag: &str) -> String {
format!("{tag}-{}.png", std::process::id())
}
fn target(id: i32, company_id: i32) -> Professional {
let mut target = generate_professional();
target.id = id;
target.company_id = company_id;
target
}
/// O JWT e a sessao precisam concordar: o extractor le papel e empresa do
/// BANCO (mock de `update_last_activity`), nao do token.
fn jwt_as(id: i32, role: &str, company_id: i32) -> Json<Value> {
let mut caller = generate_professional();
caller.id = id;
caller.role = role.to_string();
caller.company_id = company_id;
caller.to_jwt().expect("emitir o jwt do teste")
}
fn app(mut professional_mock: MockProfessionalRepository, caller: SessionState, upload_dir: &str) -> axum::Router {
professional_mock.expect_update_last_activity().return_once(move |_| Ok(caller));
let shared_state = Arc::new(AppState {
config: Config {
upload_dir: upload_dir.to_string(),
..Config::empty()
},
company_repo: Arc::new(MockCompanyRepository::new()) as DynCompanyRepository,
professional_repo: Arc::new(professional_mock) as DynProfessionalRepository,
appointment_repo: Arc::new(MockAppointmentRepository::new()) as DynAppointmentRepository,
client_repo: Arc::new(MockClientRepository::new()) as DynClientRepository,
service_repo: Arc::new(MockServiceRepository::new()) as DynServiceRepository,
availability_repo: Arc::new(MockAvailabilityRepository::new()) as DynAvailabilityRepository,
mailer: Arc::new(MockMailer::new()) as DynMailer,
});
routes().with_state(shared_state)
}
/// Mock que encontra o profissional `id` da empresa `company_id` e nada mais.
/// Sem expectativa de `update_professional`: se o handler escrever, o mockall
/// derruba o teste. E o que faz um teste de permissao valer alguma coisa.
fn finds(id: i32, company_id: i32) -> MockProfessionalRepository {
let mut professional_mock = MockProfessionalRepository::new();
professional_mock
.expect_get_professional_by_id()
.with(eq(id))
.return_once(move |_| Ok(target(id, company_id)));
professional_mock
}
const BOUNDARY: &str = "schdlrboundary";
/// Corpo multipart de um campo so, montado a mao.
fn multipart_body(file_name: Option<&str>, content: &[u8]) -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(format!("--{BOUNDARY}\r\n").as_bytes());
match file_name {
Some(name) => body.extend_from_slice(format!("Content-Disposition: form-data; name=\"file\"; filename=\"{name}\"\r\n").as_bytes()),
None => body.extend_from_slice(b"Content-Disposition: form-data; name=\"file\"\r\n"),
}
body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
body.extend_from_slice(content);
body.extend_from_slice(format!("\r\n--{BOUNDARY}--\r\n").as_bytes());
body
}
fn upload_request(id: i32, token: Json<Value>, content_type: &str, body: Vec<u8>) -> Request<Body> {
let authorization = format!("Bearer {}", token["access_token"].to_string().replace('"', ""));
Request::post(format!("/upload/{id}"))
.header("Content-Type", content_type)
.header("Authorization", authorization)
.body(Body::from(body))
.expect("montar a requisicao")
}
fn multipart_request(id: i32, token: Json<Value>, file_name: Option<&str>, content: &[u8]) -> Request<Body> {
upload_request(
id,
token,
&format!("multipart/form-data; boundary={BOUNDARY}"),
multipart_body(file_name, content),
)
}
fn png(width: u32, height: u32) -> Vec<u8> {
let raw = image::RgbImage::from_raw(width, height, vec![80; (width * height * 3) as usize]).expect("buffer do tamanho certo");
encode_png(raw)
}
/// PNG grande de verdade. Imagem lisa comprime a quase nada, entao o
/// conteudo e ruido: e o tamanho do CORPO que este PNG existe para testar.
fn noisy_png(side: u32) -> Vec<u8> {
let mut seed: u32 = 12345;
let mut raw = Vec::with_capacity((side * side * 3) as usize);
for _ in 0..side * side {
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
raw.extend_from_slice(&seed.to_le_bytes()[..3]);
}
encode_png(image::RgbImage::from_raw(side, side, raw).expect("buffer do tamanho certo"))
}
fn encode_png(raw: image::RgbImage) -> Vec<u8> {
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(raw)
.write_to(&mut encoded, ImageFormat::Png)
.expect("codificar o png");
encoded.into_inner()
}
/// O que a resposta promete que vai existir em `/api/v1/avatars`.
///
/// A data sai da mesma expressao do handler de proposito: cravar o dia faria
/// o teste apodrecer com o relogio.
fn expected_avatar(id: i32) -> String {
format!("/api/v1/avatars/{:0>5}-{}.png", id, chrono::Utc::now().format("%Y%m%d"))
}
// --- quem pode trocar o avatar de quem ---
#[tokio::test]
async fn upload_nok_when_the_professional_does_not_exist() {
let mut professional_mock = MockProfessionalRepository::new();
professional_mock
.expect_get_professional_by_id()
.with(eq(7))
.return_once(|_| Err(DataAccessError::NotFound));
let dir = UploadDir::new("sem-profissional");
let app = app(professional_mock, session_as("admin", 1), &dir.as_str());
let request = multipart_request(7, jwt_as(3, "admin", 1), Some(&unique_name("sem-profissional")), &png(10, 10));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = response_json(response).await;
assert_eq!(body["error"], "Professional does not exist");
assert!(dir.files().is_empty(), "nada podia ter sido gravado");
}
/// Falha de infra na busca nao e "nao existe": 500, nao 404.
#[tokio::test]
async fn upload_nok_when_the_lookup_fails() {
let mut professional_mock = MockProfessionalRepository::new();
professional_mock
.expect_get_professional_by_id()
.with(eq(7))
.return_once(|_| Err(DataAccessError::TechnicalError));
let dir = UploadDir::new("busca-falha");
let app = app(professional_mock, session_as("admin", 1), &dir.as_str());
let request = multipart_request(7, jwt_as(3, "admin", 1), Some(&unique_name("busca-falha")), &png(10, 10));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
/// Profissional comum nao troca a foto do colega, nem da propria empresa.
#[tokio::test]
async fn upload_nok_for_a_colleague_of_the_same_company() {
let dir = UploadDir::new("colega");
let app = app(finds(7, 1), session_as("professional", 1), &dir.as_str());
let request = multipart_request(7, jwt_as(3, "professional", 1), Some(&unique_name("colega")), &png(10, 10));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert!(dir.files().is_empty(), "nada podia ter sido gravado");
}
/// Admin manda na PROPRIA empresa: o profissional da outra nao e dele.
#[tokio::test]
async fn upload_nok_for_the_admin_of_another_company() {
let dir = UploadDir::new("admin-de-fora");
let app = app(finds(7, 1), session_as("admin", 2), &dir.as_str());
let request = multipart_request(7, jwt_as(3, "admin", 2), Some(&unique_name("admin-de-fora")), &png(10, 10));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert!(dir.files().is_empty(), "nada podia ter sido gravado");
}
// --- o que a rota recusa ---
#[tokio::test]
async fn upload_nok_without_a_file_name() {
let dir = UploadDir::new("sem-nome");
let app = app(finds(7, 1), session_as("professional", 1), &dir.as_str());
let request = multipart_request(7, jwt_as(7, "professional", 1), None, &png(10, 10));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response_json(response).await;
assert_eq!(body["error"], "Missing file name");
assert!(dir.files().is_empty(), "nada podia ter sido gravado");
}
/// Content-Type que nao e multipart nem chega ao handler: o extractor recusa
/// antes, e nenhum profissional e sequer buscado.
#[tokio::test]
async fn upload_nok_with_a_content_type_that_is_not_multipart() {
let dir = UploadDir::new("content-type");
let app = app(MockProfessionalRepository::new(), session_as("professional", 1), &dir.as_str());
let request = upload_request(7, jwt_as(7, "professional", 1), "application/json", b"{}".to_vec());
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!(dir.files().is_empty(), "nada podia ter sido gravado");
}
/// Corpo que acaba no meio do campo, sem o boundary de fechamento.
#[tokio::test]
async fn upload_nok_with_a_truncated_multipart() {
let dir = UploadDir::new("truncado");
let app = app(finds(7, 1), session_as("professional", 1), &dir.as_str());
let mut truncated = multipart_body(Some(&unique_name("truncado")), &png(10, 10));
truncated.truncate(truncated.len() - 40);
let request = upload_request(7, jwt_as(7, "professional", 1), &format!("multipart/form-data; boundary={BOUNDARY}"), truncated);
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(dir.files().is_empty(), "nada podia ter sido gravado");
}
/// O que chega nao e imagem: recusa, e o temporario nao fica para tras.
#[tokio::test]
async fn upload_nok_when_the_content_is_not_an_image() {
let dir = UploadDir::new("nao-imagem");
let app = app(finds(7, 1), session_as("professional", 1), &dir.as_str());
let file_name = unique_name("nao-imagem");
let request = multipart_request(7, jwt_as(7, "professional", 1), Some(&file_name), b"isto nao e uma imagem");
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(dir.files().is_empty(), "nada podia ter sido gravado");
assert!(!tmp_path(&file_name).exists(), "o temporario tinha que ter sido apagado");
}
/// Nome com separador de caminho nao escapa do `/tmp`: a gravacao falha e a
/// requisicao morre ali, sem tocar no cadastro.
#[tokio::test]
async fn upload_nok_with_a_hostile_file_name() {
for hostile in ["../../../../etc/passwd", "subdiretorio/avatar.png", "/etc/shadow"] {
let dir = UploadDir::new("hostil");
let app = app(finds(7, 1), session_as("professional", 1), &dir.as_str());
let request = multipart_request(7, jwt_as(7, "professional", 1), Some(hostile), &png(10, 10));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR, "nome hostil: {hostile}");
assert!(dir.files().is_empty(), "nome hostil: {hostile}");
}
}
/// Corpo maior que o limite do axum: recusado, e nada e gravado.
///
/// A resposta e 500, nao 413: o limite estourado chega como `MultipartError`
/// e o `From` de `error.rs` mapeia todo erro de multipart para
/// `InternalServerError`. Quem manda uma foto grande demais le "erro interno"
/// e nao tem como saber que bastava diminuir o arquivo. O teste crava o que a
/// API faz HOJE; o 413 seria o certo.
#[tokio::test]
async fn upload_nok_with_an_oversized_body() {
let dir = UploadDir::new("grande");
let app = app(finds(7, 1), session_as("professional", 1), &dir.as_str());
let file_name = unique_name("grande");
let huge = noisy_png(1024);
assert!(huge.len() > 2 * 1024 * 1024, "o corpo do teste precisa passar do limite: {} bytes", huge.len());
let request = multipart_request(7, jwt_as(7, "professional", 1), Some(&file_name), &huge);
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(dir.files().is_empty(), "nada podia ter sido gravado");
}
/// `upload_dir` apontando para o que nao existe: falha na hora de gravar o
/// destino, e o temporario e recolhido.
#[tokio::test]
async fn upload_nok_when_the_upload_dir_does_not_exist() {
let dir = UploadDir::new("sem-destino");
let missing = dir.0.join("nao-existe");
let app = app(finds(7, 1), session_as("professional", 1), &missing.to_string_lossy());
let file_name = unique_name("sem-destino");
let request = multipart_request(7, jwt_as(7, "professional", 1), Some(&file_name), &png(10, 10));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(!missing.exists(), "o handler nao cria o diretorio");
assert!(!tmp_path(&file_name).exists(), "o temporario tinha que ter sido apagado");
}
/// A imagem ja esta gravada quando o UPDATE falha: a resposta precisa dizer
/// que deu errado, senao o painel mostra um avatar que o cadastro nao tem.
#[tokio::test]
async fn upload_nok_when_the_update_fails() {
let mut professional_mock = finds(7, 1);
professional_mock
.expect_update_professional()
.times(1)
.return_once(|_, _| Err(DataAccessError::TechnicalError));
let dir = UploadDir::new("update-falha");
let app = app(professional_mock, session_as("professional", 1), &dir.as_str());
let request = multipart_request(7, jwt_as(7, "professional", 1), Some(&unique_name("update-falha")), &png(10, 10));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
// --- o caminho feliz ---
/// O proprio profissional troca a propria foto.
///
/// Confere as tres promessas: o que a resposta diz, o que o cadastro grava e
/// o que sobra em disco.
#[tokio::test]
async fn upload_ok_for_the_professional_itself() {
let expected = UpdateProfessional {
avatar: Some(expected_avatar(7)),
..Default::default()
};
let mut professional_mock = finds(7, 1);
professional_mock
.expect_update_professional()
// Igualdade exata: so o avatar muda, e nenhum outro campo entra de
// carona no UPDATE.
.withf(move |id, update| *id == 7 && *update == expected)
.times(1)
.return_once(|_, _| Ok(()));
let dir = UploadDir::new("feliz");
let app = app(professional_mock, session_as("professional", 1), &dir.as_str());
let file_name = unique_name("feliz");
let request = multipart_request(7, jwt_as(7, "professional", 1), Some(&file_name), &png(400, 300));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["status"], "ok");
// O arquivo que a resposta promete existe, com o nome que ela promete.
let stored = expected_avatar(7).rsplit('/').next().expect("nome do avatar").to_string();
assert_eq!(dir.files(), vec![stored.clone()], "o avatar do dia e o unico arquivo do diretorio");
// E cabe na caixa de 200x200, com a proporcao preservada.
let saved = image::open(dir.0.join(&stored)).expect("o avatar gravado e uma imagem");
assert_eq!((saved.width(), saved.height()), (200, 150));
assert!(!tmp_path(&file_name).exists(), "o temporario tinha que ter sido apagado");
}
#[tokio::test]
async fn upload_ok_for_the_admin_of_the_same_company() {
let mut professional_mock = finds(7, 1);
professional_mock.expect_update_professional().times(1).return_once(|_, _| Ok(()));
let dir = UploadDir::new("admin");
let app = app(professional_mock, session_as("admin", 1), &dir.as_str());
let request = multipart_request(7, jwt_as(3, "admin", 1), Some(&unique_name("admin")), &png(50, 50));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
/// O super-admin opera qualquer empresa, inclusive esta.
#[tokio::test]
async fn upload_ok_for_a_super_admin_of_another_company() {
let mut professional_mock = finds(7, 1);
professional_mock.expect_update_professional().times(1).return_once(|_, _| Ok(()));
let dir = UploadDir::new("super-admin");
let app = app(professional_mock, session_as("super-admin", 2), &dir.as_str());
let request = multipart_request(7, jwt_as(3, "super-admin", 2), Some(&unique_name("super-admin")), &png(50, 50));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
// --- bugs conhecidos, ver BUGS.md ---
/// Bug 30: multipart sem campo nenhum pula a gravacao inteira e mesmo assim
/// aponta o avatar para um arquivo que nunca foi criado.
#[tokio::test]
#[ignore = "bug 30"]
async fn upload_without_any_field_does_not_promise_an_avatar() {
let dir = UploadDir::new("sem-campo");
// Sem expectativa de update_professional: o mockall derruba o teste se o
// handler apontar o avatar para o arquivo que ele nao gravou.
let app = app(finds(7, 1), session_as("professional", 1), &dir.as_str());
let empty = format!("--{BOUNDARY}--\r\n").into_bytes();
let request = upload_request(7, jwt_as(7, "professional", 1), &format!("multipart/form-data; boundary={BOUNDARY}"), empty);
let response = app.oneshot(request).await.unwrap();
assert_ne!(response.status(), StatusCode::OK);
assert!(dir.files().is_empty());
}
/// Bug 55: o avatar anterior nunca e apagado. Como o nome carrega a data, uma
/// foto por dia fica no disco para sempre.
#[tokio::test]
#[ignore = "bug 55"]
async fn upload_removes_the_previous_avatar() {
let dir = UploadDir::new("avatar-antigo");
let old = "00007-20200101.png";
std::fs::write(dir.0.join(old), png(10, 10)).expect("gravar o avatar antigo");
let mut professional_mock = MockProfessionalRepository::new();
professional_mock.expect_get_professional_by_id().with(eq(7)).return_once(move |_| {
let mut target = target(7, 1);
target.avatar = format!("/api/v1/avatars/{old}");
Ok(target)
});
professional_mock.expect_update_professional().times(1).return_once(|_, _| Ok(()));
let app = app(professional_mock, session_as("professional", 1), &dir.as_str());
let request = multipart_request(7, jwt_as(7, "professional", 1), Some(&unique_name("avatar-antigo")), &png(50, 50));
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(!dir.files().contains(&old.to_string()), "o avatar de ontem ficou no disco para sempre");
}
/// Bug 18: o temporario usa o nome que veio do cliente num `/tmp` previsivel,
/// e a gravacao segue symlink. Quem plantar o link antes escolhe o arquivo que
/// o servidor sobrescreve.
#[cfg(unix)]
#[tokio::test]
#[ignore = "bug 18"]
async fn upload_does_not_write_through_a_symlinked_temporary() {
let dir = UploadDir::new("symlink");
let victim = dir.0.join("vitima.txt");
std::fs::write(&victim, b"conteudo de quem nao pediu nada").expect("gravar a vitima");
let file_name = unique_name("symlink");
let planted = tmp_path(&file_name);
let _ = std::fs::remove_file(&planted);
std::os::unix::fs::symlink(&victim, &planted).expect("plantar o symlink");
let mut professional_mock = finds(7, 1);
professional_mock.expect_update_professional().returning(|_, _| Ok(()));
let app = app(professional_mock, session_as("professional", 1), &dir.as_str());
let request = multipart_request(7, jwt_as(7, "professional", 1), Some(&file_name), &png(50, 50));
let _ = app.oneshot(request).await.unwrap();
let survived = std::fs::read(&victim).unwrap_or_default();
let _ = std::fs::remove_file(&planted);
assert_eq!(survived, b"conteudo de quem nao pediu nada", "o upload sobrescreveu o alvo do symlink");
}