ライブラリ・再生・削除を追加(Range 配信、パスの安全確認、共有ファイル名規則)
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.kazu634.com/kazu634/tver/internal/store"
|
||||
)
|
||||
|
||||
func libraryJob(id int64, relative string) store.Job {
|
||||
size := int64(10)
|
||||
finished := time.Date(2026, 10, 3, 1, 2, 3, 0, time.UTC)
|
||||
return store.Job{ID: id, EpisodeID: "ep1", SeriesTitle: `<script>番組</script>`, Title: `<b>第1話</b>`,
|
||||
BroadcasterName: "放送局", DurationSeconds: 61, ThumbnailPath: "/thumb.jpg", Status: "succeeded",
|
||||
FilePath: &relative, FileSizeBytes: &size, FinishedAt: &finished}
|
||||
}
|
||||
|
||||
func TestLibraryAndPlaybackPage(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "show/video.mp4")}}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
||||
page := getPage(handler, "/library")
|
||||
if page.Code != http.StatusOK {
|
||||
t.Fatalf("library status=%d", page.Code)
|
||||
}
|
||||
body := page.Body.String()
|
||||
for _, want := range []string{"<script>番組</script>", "<b>第1話</b>", "放送局", "長さ: 2分", "10.0 B", "2026-10-03 10:02 JST", "https://statics.tver.jp/thumb.jpg", `/library/1`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("library missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "<script>番組</script>") {
|
||||
t.Fatal("library fields were not escaped")
|
||||
}
|
||||
video := getPage(handler, "/library/1")
|
||||
if video.Code != http.StatusOK || !strings.Contains(video.Body.String(), `<video controls`) || !strings.Contains(video.Body.String(), `src="/videos/1"`) || !strings.Contains(video.Body.String(), `hx-confirm="動画ファイルと記録を削除しますか?"`) {
|
||||
t.Fatalf("video page: %d %s", video.Code, video.Body.String())
|
||||
}
|
||||
for _, path := range []string{"/library/bad", "/library/99", "/library/1/delete"} {
|
||||
response := getPage(handler, path)
|
||||
want := http.StatusNotFound
|
||||
if strings.HasSuffix(path, "/delete") {
|
||||
want = http.StatusMethodNotAllowed
|
||||
}
|
||||
if response.Code != want {
|
||||
t.Errorf("%s status=%d want=%d", path, response.Code, want)
|
||||
}
|
||||
if want == http.StatusMethodNotAllowed && !strings.Contains(response.Header().Get("Allow"), "POST") {
|
||||
t.Errorf("%s Allow=%q", path, response.Header().Get("Allow"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryRejectsInvalidJobIDs(t *testing.T) {
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "show/video.mp4")}}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, t.TempDir(), nil)
|
||||
for _, id := range []string{"0", "-1", "bad", "999999999999999999999999999999999999"} {
|
||||
for _, path := range []string{"/library/" + id, "/videos/" + id} {
|
||||
if got := getPage(handler, path).Code; got != http.StatusNotFound {
|
||||
t.Errorf("GET %s: %d", path, got)
|
||||
}
|
||||
}
|
||||
if got := postForm(handler, "/library/"+id+"/delete", nil).Code; got != http.StatusNotFound {
|
||||
t.Errorf("POST delete id=%q: %d", id, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoRangeAndMissingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(dir, "show"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := []byte("0123456789")
|
||||
if err := os.WriteFile(filepath.Join(dir, "show", "video.mp4"), content, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "show/video.mp4")}}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
||||
request := httptest.NewRequest(http.MethodGet, "/videos/1?file_path=../../outside.mp4", nil)
|
||||
request.Header.Set("Range", "bytes=2-5")
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusPartialContent || response.Body.String() != "2345" || response.Header().Get("Content-Range") != "bytes 2-5/10" || response.Header().Get("Content-Type") != "video/mp4" {
|
||||
t.Fatalf("range: status=%d range=%q type=%q body=%q", response.Code, response.Header().Get("Content-Range"), response.Header().Get("Content-Type"), response.Body.String())
|
||||
}
|
||||
full := getPage(handler, "/videos/1")
|
||||
if full.Code != http.StatusOK || full.Body.String() != string(content) || full.Header().Get("Content-Type") != "video/mp4" || full.Header().Get("Accept-Ranges") != "bytes" {
|
||||
t.Fatalf("full video: status=%d type=%q ranges=%q body=%q", full.Code, full.Header().Get("Content-Type"), full.Header().Get("Accept-Ranges"), full.Body.String())
|
||||
}
|
||||
request = httptest.NewRequest(http.MethodGet, "/videos/1", nil)
|
||||
request.Header.Set("Range", "bytes=20-30")
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusRequestedRangeNotSatisfiable || response.Header().Get("Content-Range") != "bytes */10" {
|
||||
t.Fatalf("range outside: status=%d range=%q", response.Code, response.Header().Get("Content-Range"))
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, "show", "video.mp4")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
|
||||
t.Fatalf("missing file status=%d", got)
|
||||
}
|
||||
jobs.jobs["ep1"] = store.Job{ID: 1, Status: "queued"}
|
||||
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
|
||||
t.Fatalf("unfinished status=%d", got)
|
||||
}
|
||||
if got := getPage(handler, "/library/1").Code; got != http.StatusNotFound {
|
||||
t.Fatalf("unfinished playback page status=%d", got)
|
||||
}
|
||||
missingPath := libraryJob(1, "show/video.mp4")
|
||||
missingPath.FilePath = nil
|
||||
jobs.jobs["ep1"] = missingPath
|
||||
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
|
||||
t.Fatalf("missing DB file path status=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoPathTraversalAndSymlinkAreRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
outsideFile := filepath.Join(outside, "outside.mp4")
|
||||
if err := os.WriteFile(outsideFile, []byte("secret"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsideFile, filepath.Join(dir, "link.mp4")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(dir, "linked-directory")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, relative := range []string{"../outside.mp4", "show/../../outside.mp4", outsideFile, "link.mp4", "linked-directory/outside.mp4", "linked-directory/missing.mp4"} {
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, relative)}}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
||||
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
|
||||
t.Errorf("path %q status=%d", relative, got)
|
||||
}
|
||||
deleted := postForm(handler, "/library/1/delete", nil)
|
||||
if deleted.Code != http.StatusConflict || len(jobs.jobs) != 1 {
|
||||
t.Errorf("delete path %q status=%d jobs=%d", relative, deleted.Code, len(jobs.jobs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteVideoFileThenRecord(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(dir, "show"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, "show", "video.mp4")
|
||||
if err := os.WriteFile(path, []byte("video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "show/video.mp4")}}
|
||||
fileWasGoneBeforeRecordDelete := false
|
||||
jobs.beforeDeleteSucceeded = func() {
|
||||
_, err := os.Stat(path)
|
||||
fileWasGoneBeforeRecordDelete = errors.Is(err, os.ErrNotExist)
|
||||
}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
||||
response := postForm(handler, "/library/1/delete", nil)
|
||||
if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/library" || len(jobs.jobs) != 0 || !fileWasGoneBeforeRecordDelete {
|
||||
t.Fatalf("delete: status=%d location=%q jobs=%d", response.Code, response.Header().Get("Location"), len(jobs.jobs))
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("file remains: %v", err)
|
||||
}
|
||||
jobs.jobs["ep1"] = libraryJob(1, "show/missing.mp4")
|
||||
if got := postForm(handler, "/library/1/delete", nil).Code; got != http.StatusSeeOther || len(jobs.jobs) != 0 {
|
||||
t.Fatalf("missing file delete: status=%d jobs=%d", got, len(jobs.jobs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFailureKeepsRecord(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
blocked := filepath.Join(dir, "blocked")
|
||||
if err := os.Mkdir(blocked, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(blocked, "child"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "blocked")}}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
||||
response := postForm(handler, "/library/1/delete", nil)
|
||||
if response.Code != http.StatusInternalServerError || len(jobs.jobs) != 1 || !strings.Contains(response.Body.String(), "動画ファイルを削除できませんでした") {
|
||||
t.Fatalf("failure: status=%d jobs=%d body=%s", response.Code, len(jobs.jobs), response.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user