v0.4.1: harden ISO uploads and beta UI polish
Add browser-safe chunked ISO uploads with progress, partial-file visibility, offset validation, and abort cleanup while keeping the legacy multipart endpoint for API clients. Record host-log validation coverage, keep the queue/status UI copy clean, move release docs to 0.4.1, and tighten the dark theme to a near-black Netbox-style palette.
This commit is contained in:
@@ -110,6 +110,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
metrics,
|
||||
smb: None,
|
||||
nfs,
|
||||
uploads: openpxe_http_api::uploads::UploadSessions::default(),
|
||||
log_bus,
|
||||
started_at: time::OffsetDateTime::now_utc(),
|
||||
public_base_url: "http://127.0.0.1".into(),
|
||||
@@ -1002,11 +1003,7 @@ async fn boot_log_records_entry_serve_with_mac() {
|
||||
assert_eq!(upload.status(), StatusCode::CREATED);
|
||||
|
||||
// Fetch the per-entry script with ?mac=...
|
||||
let (s, _) = get(
|
||||
&app,
|
||||
"/boot/fake-alpine-linux.ipxe?mac=AA:BB:CC:00:00:09",
|
||||
)
|
||||
.await;
|
||||
let (s, _) = get(&app, "/boot/fake-alpine-linux.ipxe?mac=AA:BB:CC:00:00:09").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
|
||||
// The boot log should now contain exactly one entry, with the
|
||||
@@ -1019,7 +1016,7 @@ async fn boot_log_records_entry_serve_with_mac() {
|
||||
let ev = &events[0];
|
||||
assert_eq!(ev["target_id"], "fake-alpine-linux");
|
||||
assert_eq!(ev["mac"], "aa:bb:cc:00:00:09"); // normalized
|
||||
// Title should include the filename and entry title.
|
||||
// Title should include the filename and entry title.
|
||||
let title = ev["target_title"].as_str().unwrap();
|
||||
assert!(title.contains("fake-alpine.iso"), "title was {title}");
|
||||
}
|
||||
@@ -1083,3 +1080,124 @@ async fn upload_rejects_non_iso_filename_with_clear_message() {
|
||||
let text = std::str::from_utf8(&body).unwrap();
|
||||
assert!(text.contains("only .iso uploads accepted"), "got: {text}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chunked_upload_writes_progressively_and_finishes_iso() {
|
||||
let (state, dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let iso = fake_alpine_iso();
|
||||
|
||||
let start = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/uploads")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"filename":"chunked-alpine.iso","size_bytes":65536}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(start.status(), StatusCode::CREATED);
|
||||
let body = axum::body::to_bytes(start.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let started: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
let upload_id = started["upload_id"].as_str().unwrap();
|
||||
|
||||
let split = 8192usize;
|
||||
let first = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri(format!("/api/uploads/{upload_id}"))
|
||||
.header("x-openpxe-upload-offset", "0")
|
||||
.body(Body::from(iso[..split].to_vec()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.status(), StatusCode::ACCEPTED);
|
||||
let body = axum::body::to_bytes(first.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let progress: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(progress["offset"].as_u64().unwrap(), split as u64);
|
||||
assert!(!progress["complete"].as_bool().unwrap());
|
||||
assert!(
|
||||
dir.path().join("isos/chunked-alpine.partial").exists(),
|
||||
"chunked upload should leave a visible partial file while in progress"
|
||||
);
|
||||
|
||||
let final_chunk = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri(format!("/api/uploads/{upload_id}"))
|
||||
.header("x-openpxe-upload-offset", split.to_string())
|
||||
.header("x-openpxe-upload-complete", "true")
|
||||
.body(Body::from(iso[split..].to_vec()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(final_chunk.status(), StatusCode::CREATED);
|
||||
let body = axum::body::to_bytes(final_chunk.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let finished: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(finished["complete"].as_bool().unwrap());
|
||||
assert_eq!(finished["iso"]["id"], "chunked-alpine");
|
||||
assert!(dir.path().join("isos/chunked-alpine.iso").exists());
|
||||
assert!(!dir.path().join("isos/chunked-alpine.partial").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chunked_upload_rejects_offset_mismatch_without_advancing() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
|
||||
let start = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/uploads")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"filename":"offset-test.iso","size_bytes":16}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(start.status(), StatusCode::CREATED);
|
||||
let body = axum::body::to_bytes(start.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let started: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
let upload_id = started["upload_id"].as_str().unwrap();
|
||||
|
||||
let mismatch = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri(format!("/api/uploads/{upload_id}"))
|
||||
.header("x-openpxe-upload-offset", "8")
|
||||
.body(Body::from(vec![1, 2, 3, 4]))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(mismatch.status(), StatusCode::CONFLICT);
|
||||
let body = axum::body::to_bytes(mismatch.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(text.contains("expected offset 0"), "got: {text}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user