Found by walking every handler and the UI with odd inputs, then a battery of requests against the live hub. Fixed: - A save over the 8 MiB cap was cut short in place: the file was truncated, then the first 8 MiB written, and the caller told 204. Now the body is read whole first and a body over the cap is a 413 with the file untouched. - A link to a file showed the link's own time; now the file's, so the row reads right and the preview key follows the file. - Reading a link to nothing answered 200 with an empty body (the open failed after the headers in root mode); now a 404 that names the target. - A play request with an unknown codec or size was answered by inspecting the file first, so an audio-only file said "no video stream" instead of what was wrong; the choice is checked before any work, and is a 400. - A stream that ended in failure was handed back to the next identical request for ten minutes; a failed one is now dropped and a fresh one made. - A player that left while its stream was still finding a first segment made the loop try the next recipe; it stops. A stream closed mid-start is not started again. - Ending a root-mode ffmpeg could wait forever: the daemon cannot signal root's processes, so the wait is bounded and the helper's own grace does the rest. - A page going away (a phone closing the tab) releases its stream by a beacon, rather than ten minutes later by the sweeper. - The path box resolves like a shell — relative to the folder shown, . and .. applied, trailing slashes dropped — in the tab and in the Move picker, with a test. A typed path that is a file opens it; a remembered folder that is gone falls back to / once. - A dropped folder arrived as a nameless empty file; it is refused with a word. The upload toast counts what was actually uploaded. - The viewer names a device, pipe or socket, and a dangling link, instead of calling them binary files. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
105 lines
3.5 KiB
Go
105 lines
3.5 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"hamlab/internal/files"
|
|
)
|
|
|
|
// A file read answers Range requests — what a <video> needs to seek — and HEAD, through
|
|
// the same handler that streams everything else.
|
|
func TestFilesReadRanges(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "clip.mp4")
|
|
data := make([]byte, 10000)
|
|
for i := range data {
|
|
data[i] = byte(i % 251)
|
|
}
|
|
if err := os.WriteFile(p, data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := &Server{fsLocal: files.Local{}}
|
|
get := func(method, extra string, hdr map[string]string) *httptest.ResponseRecorder {
|
|
r := httptest.NewRequest(method, "/api/files/read?path="+url.QueryEscape(p)+extra, nil)
|
|
for k, v := range hdr {
|
|
r.Header.Set(k, v)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
s.filesRead(w, r)
|
|
return w
|
|
}
|
|
w := get("GET", "", nil)
|
|
if w.Code != 200 || w.Body.Len() != 10000 || w.Header().Get("Content-Type") != "video/mp4" || w.Header().Get("Accept-Ranges") != "bytes" {
|
|
t.Fatalf("whole: %d %d %q %q", w.Code, w.Body.Len(), w.Header().Get("Content-Type"), w.Header().Get("Accept-Ranges"))
|
|
}
|
|
w = get("GET", "", map[string]string{"Range": "bytes=100-199"})
|
|
if w.Code != 206 || w.Header().Get("Content-Range") != "bytes 100-199/10000" || w.Body.Len() != 100 || w.Body.Bytes()[0] != data[100] {
|
|
t.Fatalf("range: %d %q %d", w.Code, w.Header().Get("Content-Range"), w.Body.Len())
|
|
}
|
|
w = get("GET", "", map[string]string{"Range": "bytes=9990-"})
|
|
if w.Code != 206 || w.Body.Len() != 10 || w.Body.Bytes()[9] != data[9999] {
|
|
t.Fatalf("tail range: %d %d", w.Code, w.Body.Len())
|
|
}
|
|
w = get("HEAD", "", nil)
|
|
if w.Code != 200 || w.Body.Len() != 0 || w.Header().Get("Content-Length") != "10000" {
|
|
t.Fatalf("head: %d %d %q", w.Code, w.Body.Len(), w.Header().Get("Content-Length"))
|
|
}
|
|
if w := get("GET", "&max=50", nil); w.Code != 413 {
|
|
t.Fatalf("the editor's cap: %d", w.Code)
|
|
}
|
|
if w := get("GET", "", map[string]string{"Range": "bytes=20000-"}); w.Code != 416 {
|
|
t.Fatalf("a range past the end: %d", w.Code)
|
|
}
|
|
if err := os.Chmod(p, 0); err == nil && os.Geteuid() != 0 {
|
|
if w := get("GET", "", nil); w.Code != 403 {
|
|
t.Fatalf("unreadable: %d", w.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A write over the cap is refused whole: the file is not truncated first.
|
|
func TestFilesWriteCap(t *testing.T) {
|
|
p := filepath.Join(t.TempDir(), "f.txt")
|
|
if err := os.WriteFile(p, []byte("keep me"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := &Server{fsLocal: files.Local{}}
|
|
body := make([]byte, 8<<20+1)
|
|
r := httptest.NewRequest("PUT", "/api/files/write?path="+url.QueryEscape(p), bytes.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
s.filesWrite(w, r)
|
|
if w.Code != 413 {
|
|
t.Fatalf("got %d", w.Code)
|
|
}
|
|
if b, _ := os.ReadFile(p); string(b) != "keep me" {
|
|
t.Fatalf("the file was touched: %q", b)
|
|
}
|
|
r = httptest.NewRequest("PUT", "/api/files/write?path="+url.QueryEscape(p), bytes.NewReader([]byte("new")))
|
|
w = httptest.NewRecorder()
|
|
s.filesWrite(w, r)
|
|
if b, _ := os.ReadFile(p); w.Code != 204 || string(b) != "new" {
|
|
t.Fatalf("a write within the cap: %d %q", w.Code, b)
|
|
}
|
|
}
|
|
|
|
// A link to nothing is a 404, not an empty 200.
|
|
func TestFilesReadDanglingLink(t *testing.T) {
|
|
dir := t.TempDir()
|
|
l := filepath.Join(dir, "broken")
|
|
if err := os.Symlink("nowhere", l); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := &Server{fsLocal: files.Local{}}
|
|
r := httptest.NewRequest("GET", "/api/files/read?path="+url.QueryEscape(l), nil)
|
|
w := httptest.NewRecorder()
|
|
s.filesRead(w, r)
|
|
if w.Code != 404 {
|
|
t.Fatalf("got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|