🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! 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(())
}