use std::collections::HashMap;
use topcoat::context::Cx;
use super::{
super::test_support::{attributes_of, cx, opening_tag_at, tag_with},
*,
};
use crate::{
form::FieldErrors,
schema::{Schema, Source},
};
#[tokio::test]
async fn file_upload_renders_without_value_attr() {
let cx = cx();
#[derive(Debug, toasty::Model)]
struct Doc {
#[key]
#[auto]
id: uuid::Uuid,
path: String,
}
let schema = Schema::new(Field::file(Doc::fields().path()));
let mut values = HashMap::new();
values.insert("path".to_string(), "/tmp/old.jpg".to_string());
let html = schema
.render(&cx, Source::form(&values, &FieldErrors::new()))
.await
.unwrap()
.single()
.await
.unwrap()
.render(&cx);
assert!(
html.contains("type=\"file\""),
"missing file input in {html}"
);
assert!(
!html.contains("value=\"/tmp/old.jpg\""),
"file input must not carry value in {html}"
);
}
fn file_input_tag(html: &str) -> String {
let at = html.find("type=\"file\"").expect("a file input");
let start = html[..at].rfind("<input").expect("its opening tag");
opening_tag_at(html, start).to_string()
}
fn cx_and_doc_schema() -> (Cx, Schema) {
#[derive(Debug, toasty::Model)]
struct Upload {
#[key]
#[auto]
id: uuid::Uuid,
path: String,
}
(cx(), Schema::new(Field::file(Upload::fields().path())))
}
async fn render_upload(schema: &Schema, cx: &Cx, value: Option<&str>) -> String {
let mut values = HashMap::new();
if let Some(value) = value {
values.insert("path".to_string(), value.to_string());
}
schema
.render(cx, Source::form(&values, &FieldErrors::new()))
.await
.unwrap()
.single()
.await
.unwrap()
.render(cx)
}
async fn render_readonly_upload(schema: &Schema, cx: &Cx, value: Option<&str>) -> String {
let mut values = HashMap::new();
if let Some(value) = value {
values.insert("path".to_string(), value.to_string());
}
schema
.render(cx, Source::view(&values))
.await
.unwrap()
.single()
.await
.unwrap()
.render(cx)
}
#[tokio::test]
async fn file_upload_is_required_on_create() {
let (cx, schema) = cx_and_doc_schema();
let html = render_upload(&schema, &cx, None).await;
let tag = file_input_tag(&html);
assert!(
tag.contains("required"),
"a create must keep the required file control, got {tag}"
);
assert!(
!html.contains("data-file-current"),
"a create has no stored path to show, got {html}"
);
assert!(
!html.contains("Leave empty"),
"the keep-current hint is an edit affordance, got {html}"
);
}
#[tokio::test]
async fn file_upload_surfaces_the_stored_path_and_drops_required() {
let (cx, schema) = cx_and_doc_schema();
let html = render_upload(&schema, &cx, Some("/uploads/cover.jpg")).await;
let tag = file_input_tag(&html);
assert!(
!tag.contains("required"),
"an edit must not block on the empty file control, got {tag}"
);
assert!(
tag.contains("aria-describedby=\"path-hint\""),
"the control must describe itself with the hint, got {tag}"
);
assert!(
html.contains("data-file-current=\"/uploads/cover.jpg\""),
"the stored path must be surfaced, got {html}"
);
assert!(
html.contains("Leave empty to keep the current file."),
"the edit must say an empty control keeps the file, got {html}"
);
}
#[tokio::test]
async fn file_upload_treats_a_blank_stored_path_as_empty() {
let (cx, schema) = cx_and_doc_schema();
let html = render_upload(&schema, &cx, Some(" ")).await;
let tag = file_input_tag(&html);
assert!(
tag.contains("required"),
"a blank stored path must stay required, got {tag}"
);
assert!(
!html.contains("data-file-current"),
"a blank stored path must not render a Current line, got {html}"
);
}
#[tokio::test]
async fn file_upload_links_the_stored_file() {
let (cx, schema) = cx_and_doc_schema();
let image = render_upload(&schema, &cx, Some("/uploads/cover.png")).await;
assert!(
!image.contains("<img"),
"a stored path is never rendered as an image, got {image}"
);
assert!(
tag_with(&image, "href=\"/uploads/cover.png\"").starts_with("<a"),
"the stored path must be a link to the file, got {image}"
);
assert!(
image.contains("data-file-current=\"/uploads/cover.png\""),
"the path stays readable beside the link, got {image}"
);
let signed = render_upload(&schema, &cx, Some("/media/photo.JPG?token=abc")).await;
assert!(
tag_with(&signed, "href=\"/media/photo.JPG?token=abc\"").starts_with("<a"),
"the stored path is rendered verbatim, got {signed}"
);
}
#[tokio::test]
async fn file_upload_view_mode_links_the_stored_file() {
let (cx, schema) = cx_and_doc_schema();
let html = render_readonly_upload(&schema, &cx, Some("/uploads/cover.png")).await;
assert!(
tag_with(&html, "href=\"/uploads/cover.png\"").starts_with("<a"),
"a detail page must link the stored file, got {html}"
);
assert!(
!html.contains("<img"),
"a detail page renders no image, got {html}"
);
assert!(
!html.contains("type=\"file\""),
"and must never show a file control, got {html}"
);
let empty = render_readonly_upload(&schema, &cx, Some("")).await;
assert!(
!empty.contains("href="),
"an empty stored value must not render a link, got {empty}"
);
}
#[tokio::test]
async fn file_upload_links_only_a_rooted_or_http_url() {
let (cx, schema) = cx_and_doc_schema();
for refused in [
"javascript:alert(1)",
"JavaScript:alert(1)",
"data:text/html,x",
"//evil.example/x.png",
"report.pdf",
] {
let edit = render_upload(&schema, &cx, Some(refused)).await;
assert!(
!edit.contains("href="),
"{refused} must not become a link on the form, got {edit}"
);
assert!(
edit.contains(&format!("data-file-current=\"{refused}\"")),
"{refused} must stay visible on the form, got {edit}"
);
let view = render_readonly_upload(&schema, &cx, Some(refused)).await;
assert!(
!view.contains("href="),
"{refused} must not become a link on the detail page, got {view}"
);
assert!(
view.contains(refused),
"{refused} must still render as text on the detail page, got {view}"
);
}
for linkable in ["/uploads/a.png", "https://cdn.example/a.png"] {
let edit = render_upload(&schema, &cx, Some(linkable)).await;
assert!(
tag_with(&edit, &format!("href=\"{linkable}\"")).starts_with("<a"),
"{linkable} must stay a link on the form, got {edit}"
);
let view = render_readonly_upload(&schema, &cx, Some(linkable)).await;
assert!(
tag_with(&view, &format!("href=\"{linkable}\"")).starts_with("<a"),
"{linkable} must stay a link on the detail page, got {view}"
);
}
}
#[tokio::test]
async fn file_upload_renders_any_extension_identically() {
let (cx, schema) = cx_and_doc_schema();
let png = render_upload(&schema, &cx, Some("/uploads/cover.png")).await;
let txt = render_upload(&schema, &cx, Some("/uploads/cover.txt")).await;
for needle in ["data-file-current=", "href="] {
assert_eq!(
stored_attributes(&png, needle, "/uploads/cover.png"),
stored_attributes(&txt, needle, "/uploads/cover.txt"),
"the extension must not change the form row ({needle})"
);
}
let png = render_readonly_upload(&schema, &cx, Some("/uploads/cover.png")).await;
let txt = render_readonly_upload(&schema, &cx, Some("/uploads/cover.txt")).await;
assert_eq!(
stored_attributes(&png, "href=", "/uploads/cover.png"),
stored_attributes(&txt, "href=", "/uploads/cover.txt"),
"the extension must not change the read-only link"
);
}
fn stored_attributes(html: &str, needle: &str, path: &str) -> Vec<String> {
attributes_of(html, needle)
.into_iter()
.map(|attr| attr.replace(path, "<stored>"))
.collect()
}
#[tokio::test]
async fn file_upload_offers_the_clear_control_only_for_a_stored_value() {
let (cx, schema) = cx_and_doc_schema();
let edit = render_upload(&schema, &cx, Some("/uploads/cover.png")).await;
assert!(
edit.contains("name=\"clear_path\""),
"an edit must post the declared transport key, got {edit}"
);
assert!(
edit.contains("value=\"1\""),
"the control must carry the truthy value the handlers read, got {edit}"
);
assert!(
edit.contains("Remove the current file"),
"the label must say what ticking it does, got {edit}"
);
let create = render_upload(&schema, &cx, None).await;
assert!(
!create.contains("name=\"clear_path\""),
"a create has nothing to clear, got {create}"
);
}