//! Integration tests for note attachments.
//!
//! Tests the full stack: HTTP endpoints, database, file storage.
//! Attachments can be uploaded with notes, downloaded, and deleted by the
//! owner.
mod support;
use support::{Client, Server, free_port};
/// The password the tests register and log in with.
const PASSWORD: &str = "correct horse battery staple";
fn spawn() -> (Server, u16) {
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
(server, port)
}
/// Registers `name` through the form, which logs them in.
fn register(client: &mut Client, name: &str) {
let email = format!("{name}@example.org");
let response = client.post_form(
"/register",
&[("name", name), ("email", &email), ("password", PASSWORD)],
);
assert_eq!(
response.status, 303,
"registering should log in\n{}",
response.body
);
}
/// Extracts (note_uuid, attachment_uuid) from the profile page HTML.
/// The attachment link looks like:
/// `/~{user}/notes/{note_uuid}/attachments/{attachment_uuid}`
fn extract_note_and_attachment_uuid(body: &str) -> Option<(String, String)> {
let marker = "/notes/";
let start = body.find(marker)? + marker.len();
let rest = &body[start..];
let note_end = rest.find("/attachments/")?;
let note_uuid = &rest[..note_end];
let after = &rest[note_end + "/attachments/".len()..];
let att_end = after.find('"')?;
let attachment_uuid = &after[..att_end];
Some((note_uuid.to_string(), attachment_uuid.to_string()))
}
#[tokio::test]
async fn can_upload_attachment_to_note() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
// Post a note with an attachment
let response = alice.post_multipart(
"/~alice/notes",
&[("content", "Test note with attachment")],
&[(
"attachment",
"test.txt",
"text/plain",
b"Hello, this is a test file!".to_vec(),
)],
);
assert_eq!(
response.status, 303,
"note creation should redirect\n{}",
response.body
);
// Verify attachment appears on profile page
let response = alice.get("/~alice");
assert_eq!(
response.status, 200,
"profile page should load\n{}",
response.body
);
assert!(
response.body.contains("test.txt"),
"attachment filename should appear on profile page, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn can_download_attachment() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
// Post a note with an attachment
let response = alice.post_multipart(
"/~alice/notes",
&[("content", "Test note with attachment")],
&[(
"attachment",
"test.txt",
"text/plain",
b"Hello, this is a test file!".to_vec(),
)],
);
assert_eq!(response.status, 303);
// Get the profile page to find both UUIDs
let response = alice.get("/~alice");
assert_eq!(response.status, 200);
let (note_uuid, attachment_uuid) = extract_note_and_attachment_uuid(&response.body)
.expect("should find note and attachment UUIDs");
// Download the attachment
let response = alice.get(&format!(
"/~alice/notes/{note_uuid}/attachments/{attachment_uuid}"
));
assert_eq!(
response.status, 200,
"attachment should be downloadable\n{}",
response.body
);
assert!(
response.body.contains("Hello, this is a test file!"),
"attachment content should match, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn note_owner_can_delete_attachment() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
// Post a note with an attachment
let response = alice.post_multipart(
"/~alice/notes",
&[("content", "Test note with attachment")],
&[(
"attachment",
"test.txt",
"text/plain",
b"Hello, this is a test file!".to_vec(),
)],
);
assert_eq!(response.status, 303);
// Get the profile page to find both UUIDs
let response = alice.get("/~alice");
assert_eq!(response.status, 200);
let (note_uuid, attachment_uuid) = extract_note_and_attachment_uuid(&response.body)
.expect("should find note and attachment UUIDs");
// Delete the attachment
let response = alice.post_form(
&format!("/~alice/notes/{note_uuid}/attachments/{attachment_uuid}/delete"),
&[],
);
assert_eq!(
response.status, 303,
"deleting attachment should redirect\n{}",
response.body
);
// Verify attachment was deleted
let response = alice.get("/~alice");
assert_eq!(response.status, 200);
assert!(
!response.body.contains("test.txt"),
"attachment should be deleted, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn non_owner_cannot_delete_attachment() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
// Alice posts a note with an attachment
let response = alice.post_multipart(
"/~alice/notes",
&[("content", "Alice's note with attachment")],
&[(
"attachment",
"alice.txt",
"text/plain",
b"Alice's file content".to_vec(),
)],
);
assert_eq!(response.status, 303);
// Get the profile page to find both UUIDs
let response = alice.get("/~alice");
assert_eq!(response.status, 200);
let (note_uuid, attachment_uuid) = extract_note_and_attachment_uuid(&response.body)
.expect("should find note and attachment UUIDs");
// Bob tries to delete it
let mut bob = Client::new(port);
register(&mut bob, "bob");
let response = bob.post_form(
&format!("/~alice/notes/{note_uuid}/attachments/{attachment_uuid}/delete"),
&[],
);
assert_eq!(
response.status, 403,
"bob should not be able to delete alice's attachment\n{}",
response.body
);
// Verify attachment still exists
let response = alice.get("/~alice");
assert_eq!(response.status, 200);
assert!(
response.body.contains("alice.txt"),
"attachment should still exist, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn unauthenticated_user_cannot_upload() -> miette::Result<()> {
let (_server, port) = spawn();
let mut anon = Client::new(port);
// Try to post a note with an attachment without being logged in
let response = anon.post_multipart(
"/~alice/notes",
&[("content", "Anonymous note")],
&[(
"attachment",
"test.txt",
"text/plain",
b"This should be rejected".to_vec(),
)],
);
// Should redirect to login or reject
assert!(
response.status == 401
|| (response.status == 303 && response.location.as_deref() == Some("/login")),
"unauthenticated user should be rejected or redirected to login, got status {} location {:?}\n{}",
response.status,
response.location,
response.body
);
Ok(())
}
#[tokio::test]
async fn attachment_appears_in_feed() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
// Post a note with an attachment
let response = alice.post_multipart(
"/~alice/notes",
&[("content", "Note with attachment in feed")],
&[(
"attachment",
"feed.txt",
"text/plain",
b"File in feed".to_vec(),
)],
);
assert_eq!(response.status, 303);
// Check the home feed
let response = alice.get("/");
assert_eq!(
response.status, 200,
"home feed should load\n{}",
response.body
);
assert!(
response.body.contains("feed.txt"),
"attachment filename should appear in feed, got:\n{}",
response.body
);
Ok(())
}