640 lines
28 KiB
Go
640 lines
28 KiB
Go
package web
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"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", SeriesID: "series1", 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>", "放送局", "1番組・1本・0.00 GB", "10.0 B", "2026-10-03 10:02 JST", "https://statics.tver.jp/thumb.jpg", `/library/series/series1`, "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(), `action="/library/delete/confirm"`) || strings.Contains(video.Body.String(), `hx-confirm`) || !strings.Contains(video.Body.String(), `/library/series/series1`) || !strings.Contains(video.Body.String(), `前の回はありません`) || !strings.Contains(video.Body.String(), `次の回はありません`) {
|
|
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 response.Code != want {
|
|
t.Errorf("%s status=%d want=%d", path, response.Code, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLibrarySeriesPageSortingSubscriptionAndNotFound(t *testing.T) {
|
|
old := libraryJob(2, "show/old.mp4")
|
|
old.EpisodeID = "ep2"
|
|
old.Title = "第2話"
|
|
old.SeriesTitle = "旧番組名"
|
|
old.BroadcasterName = "旧局"
|
|
oldFinished := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
|
old.FinishedAt = &oldFinished
|
|
latest := libraryJob(10, "show/latest.mp4")
|
|
latest.EpisodeID = "ep10"
|
|
latest.Title = "第10話"
|
|
latest.SeriesTitle = `<em>新番組名</em>`
|
|
latest.BroadcasterName = "新局"
|
|
latestFinished := oldFinished.Add(time.Hour)
|
|
latest.FinishedAt = &latestFinished
|
|
jobs := &fakeJobStore{
|
|
jobs: map[string]store.Job{"ep2": old, "ep10": latest},
|
|
subscriptions: map[string]store.Subscription{"series1": {SeriesID: "series1"}},
|
|
}
|
|
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, t.TempDir(), nil)
|
|
library := getPage(handler, "/library")
|
|
if library.Code != http.StatusOK || !strings.Contains(library.Body.String(), "登録中") || !strings.Contains(library.Body.String(), "2本") {
|
|
t.Fatalf("library series card: status=%d body=%s", library.Code, library.Body.String())
|
|
}
|
|
|
|
page := getPage(handler, "/library/series/series1")
|
|
if page.Code != http.StatusOK {
|
|
t.Fatalf("series status=%d body=%s", page.Code, page.Body.String())
|
|
}
|
|
body := page.Body.String()
|
|
for _, want := range []string{"ライブラリ", "<em>新番組名</em>", "登録中", "新局・2本・20.0 B", `/library/10`, `/library/2`, `?sort=natural`} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("series page missing %q", want)
|
|
}
|
|
}
|
|
if strings.Index(body, `/library/10`) > strings.Index(body, `/library/2`) {
|
|
t.Fatal("default order is not latest first")
|
|
}
|
|
natural := getPage(handler, "/library/series/series1?sort=natural")
|
|
if natural.Code != http.StatusOK || strings.Index(natural.Body.String(), `/library/2`) > strings.Index(natural.Body.String(), `/library/10`) {
|
|
t.Fatalf("natural order: status=%d body=%s", natural.Code, natural.Body.String())
|
|
}
|
|
if got := getPage(handler, "/library/series/missing").Code; got != http.StatusNotFound {
|
|
t.Fatalf("missing series status=%d", got)
|
|
}
|
|
if got := getPage(handler, "/library/series/bad-id").Code; got != http.StatusNotFound {
|
|
t.Fatalf("invalid series status=%d", got)
|
|
}
|
|
if got := getPage(handler, "/library/series/series1?sort=bad").Code; got != http.StatusBadRequest {
|
|
t.Fatalf("invalid sort status=%d", got)
|
|
}
|
|
}
|
|
|
|
func TestLibraryOmitsSubscriptionMarkerForUnregisteredSeries(t *testing.T) {
|
|
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "show/video.mp4")}}
|
|
page := getPage(newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, t.TempDir(), nil), "/library")
|
|
if page.Code != http.StatusOK || strings.Contains(page.Body.String(), "登録中") {
|
|
t.Fatalf("unregistered series: status=%d body=%s", page.Code, page.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestLibraryPagesDoNotInspectVideoDirectory(t *testing.T) {
|
|
missingVideoDir := filepath.Join(t.TempDir(), "missing-video-root")
|
|
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "missing/video.mp4")}}
|
|
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, missingVideoDir, nil)
|
|
for _, path := range []string{"/library", "/library/series/series1"} {
|
|
response := getPage(handler, path)
|
|
if response.Code != http.StatusOK {
|
|
t.Errorf("DB由来の一覧 %s: status=%d body=%s", path, response.Code, response.Body.String())
|
|
}
|
|
}
|
|
if _, err := os.Stat(missingVideoDir); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("一覧表示が動画ルートを作成または走査した: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPlaybackPreviousAndNextNaturalOrder(t *testing.T) {
|
|
jobs := make(map[string]store.Job)
|
|
for _, data := range []struct {
|
|
id int64
|
|
epid string
|
|
title string
|
|
}{{2, "ep2", "第2話"}, {10, "ep10", "第10話"}, {9, "ep9", "第9話"}} {
|
|
job := libraryJob(data.id, "show/video.mp4")
|
|
job.EpisodeID = data.epid
|
|
job.Title = data.title
|
|
jobs[data.epid] = job
|
|
}
|
|
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, &fakeJobStore{jobs: jobs}, t.TempDir(), nil)
|
|
middle := getPage(handler, "/library/9")
|
|
for _, want := range []string{`href="/library/2"`, `前の回: 第2話`, `href="/library/10"`, `次の回: 第10話`} {
|
|
if !strings.Contains(middle.Body.String(), want) {
|
|
t.Errorf("middle playback missing %q", want)
|
|
}
|
|
}
|
|
first := getPage(handler, "/library/2")
|
|
if !strings.Contains(first.Body.String(), "前の回はありません") || !strings.Contains(first.Body.String(), `href="/library/9"`) {
|
|
t.Fatalf("first playback navigation: %s", first.Body.String())
|
|
}
|
|
last := getPage(handler, "/library/10")
|
|
if !strings.Contains(last.Body.String(), "次の回はありません") || !strings.Contains(last.Body.String(), `href="/library/9"`) {
|
|
t.Fatalf("last playback navigation: %s", last.Body.String())
|
|
}
|
|
}
|
|
|
|
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/delete", url.Values{"id": {id}}).Code; got != http.StatusBadRequest {
|
|
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)
|
|
}
|
|
relativeOutsideFile, err := filepath.Rel(dir, outsideFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
relativeOutsideDir, err := filepath.Rel(dir, outside)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(relativeOutsideFile, filepath.Join(dir, "relative-link.mp4")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(relativeOutsideDir, filepath.Join(dir, "relative-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",
|
|
"relative-link.mp4", "relative-linked-directory/outside.mp4", "relative-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/delete", url.Values{"id": {"1"}})
|
|
if deleted.Code != http.StatusOK || len(jobs.jobs) != 1 || !strings.Contains(deleted.Body.String(), "失敗: 1件") {
|
|
t.Errorf("delete path %q status=%d jobs=%d", relative, deleted.Code, len(jobs.jobs))
|
|
}
|
|
}
|
|
if got, err := os.ReadFile(outsideFile); err != nil || string(got) != "secret" {
|
|
t.Fatalf("outside target was changed: content=%q err=%v", got, err)
|
|
}
|
|
for _, link := range []string{"link.mp4", "linked-directory", "relative-link.mp4", "relative-linked-directory"} {
|
|
if _, err := os.Lstat(filepath.Join(dir, link)); err != nil {
|
|
t.Errorf("rejected link %q was changed: %v", link, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVideoSymlinkWithinRootCanBeServedAndDeleted(t *testing.T) {
|
|
dir := t.TempDir()
|
|
target := filepath.Join(dir, "target.mp4")
|
|
if err := os.WriteFile(target, []byte("video"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink("target.mp4", filepath.Join(dir, "link.mp4")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "link.mp4")}}
|
|
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
|
if response := getPage(handler, "/videos/1"); response.Code != http.StatusOK || response.Body.String() != "video" {
|
|
t.Fatalf("serve: status=%d body=%q", response.Code, response.Body.String())
|
|
}
|
|
if response := postForm(handler, "/library/delete", url.Values{"id": {"1"}}); response.Code != http.StatusOK || len(jobs.jobs) != 0 {
|
|
t.Fatalf("delete: status=%d jobs=%d", response.Code, len(jobs.jobs))
|
|
}
|
|
if _, err := os.Stat(target); err != nil {
|
|
t.Fatalf("symlink target was removed: %v", err)
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(dir, "link.mp4")); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("symlink itself remains: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVideoSymlinkedDirectoryWithinRootCanBeServedAndDeleted(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.Mkdir(filepath.Join(dir, "target"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
target := filepath.Join(dir, "target", "video.mp4")
|
|
if err := os.WriteFile(target, []byte("video"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink("target", filepath.Join(dir, "linked-directory")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "linked-directory/video.mp4")}}
|
|
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
|
if response := getPage(handler, "/videos/1"); response.Code != http.StatusOK || response.Body.String() != "video" {
|
|
t.Fatalf("serve: status=%d body=%q", response.Code, response.Body.String())
|
|
}
|
|
if response := postForm(handler, "/library/delete", url.Values{"id": {"1"}}); response.Code != http.StatusOK || len(jobs.jobs) != 0 {
|
|
t.Fatalf("delete: status=%d jobs=%d", response.Code, len(jobs.jobs))
|
|
}
|
|
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("target file remains: %v", err)
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(dir, "linked-directory")); err != nil {
|
|
t.Fatalf("directory symlink was removed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVideoAbsoluteSymlinkWithinRootIsRejectedByOSRoot(t *testing.T) {
|
|
dir := t.TempDir()
|
|
target := filepath.Join(dir, "target.mp4")
|
|
if err := os.WriteFile(target, []byte("video"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(target, filepath.Join(dir, "absolute-link.mp4")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "absolute-link.mp4")}}
|
|
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
|
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
|
|
t.Fatalf("absolute symlink serve status=%d", got)
|
|
}
|
|
if response := postForm(handler, "/library/delete", url.Values{"id": {"1"}}); response.Code != http.StatusOK || len(jobs.jobs) != 1 || !strings.Contains(response.Body.String(), "失敗: 1件") {
|
|
t.Fatalf("absolute symlink delete: status=%d jobs=%d", response.Code, len(jobs.jobs))
|
|
}
|
|
if _, err := os.Stat(target); err != nil {
|
|
t.Fatalf("target was changed: %v", err)
|
|
}
|
|
}
|
|
|
|
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/delete", url.Values{"id": {"1"}})
|
|
if response.Code != http.StatusOK || len(jobs.jobs) != 0 || !fileWasGoneBeforeRecordDelete || !strings.Contains(response.Body.String(), "削除: 1件") {
|
|
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)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "show")); !os.IsNotExist(err) {
|
|
t.Fatalf("empty series folder remains: %v", err)
|
|
}
|
|
jobs.jobs["ep1"] = libraryJob(1, "show/missing.mp4")
|
|
if got := postForm(handler, "/library/delete", url.Values{"id": {"1"}}).Code; got != http.StatusOK || len(jobs.jobs) != 0 {
|
|
t.Fatalf("missing file delete: status=%d jobs=%d", got, len(jobs.jobs))
|
|
}
|
|
}
|
|
|
|
func TestDeleteRedirectsToSeriesWhenEpisodesRemainAndKeepsNonemptyFolder(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.Mkdir(filepath.Join(dir, "show"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "show", "one.mp4"), []byte("one"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "show", "two.mp4"), []byte("two"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first := libraryJob(1, "show/one.mp4")
|
|
second := libraryJob(2, "show/two.mp4")
|
|
second.EpisodeID = "ep2"
|
|
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": first, "ep2": second}}
|
|
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
|
response := postForm(handler, "/library/delete", url.Values{"id": {"1"}})
|
|
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "/library/series/series1") || len(jobs.jobs) != 1 {
|
|
t.Fatalf("delete: status=%d location=%q jobs=%d", response.Code, response.Header().Get("Location"), len(jobs.jobs))
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "show")); err != nil {
|
|
t.Fatalf("nonempty series folder removed: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "show", "two.mp4")); err != nil {
|
|
t.Fatalf("remaining video removed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeleteVideoAtRootNeverRemovesVideoRoot(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "video.mp4"), []byte("video"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "video.mp4")}}
|
|
response := postForm(newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil), "/library/delete", url.Values{"id": {"1"}})
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("delete status=%d", response.Code)
|
|
}
|
|
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
|
t.Fatalf("video root was removed: info=%v err=%v", info, err)
|
|
}
|
|
}
|
|
|
|
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/delete", url.Values{"id": {"1"}})
|
|
if response.Code != http.StatusOK || len(jobs.jobs) != 1 || !strings.Contains(response.Body.String(), "失敗: 1件") {
|
|
t.Fatalf("failure: status=%d jobs=%d body=%s", response.Code, len(jobs.jobs), response.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestDeleteConfirmationSelectionValidationAndDeduplication(t *testing.T) {
|
|
first := libraryJob(1, "show/one.mp4")
|
|
second := libraryJob(2, "show/two.mp4")
|
|
second.EpisodeID = "ep2"
|
|
second.Title = `<img src=x onerror=alert(1)>`
|
|
third := libraryJob(3, "other/three.mp4")
|
|
third.EpisodeID = "ep3"
|
|
third.SeriesID = "series2"
|
|
third.SeriesTitle = "別番組"
|
|
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": first, "ep2": second, "ep3": third}}
|
|
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, t.TempDir(), nil)
|
|
|
|
for name, values := range map[string]url.Values{
|
|
"IDのみ": {"id": {"1"}},
|
|
"番組のみ": {"series_id": {"series2"}},
|
|
"両方と重複除去": {"id": {"1"}, "series_id": {"series1"}},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
response := postForm(handler, "/library/delete/confirm", values)
|
|
if response.Code != http.StatusOK || len(jobs.jobs) != 3 {
|
|
t.Fatalf("status=%d jobs=%d body=%s", response.Code, len(jobs.jobs), response.Body.String())
|
|
}
|
|
if strings.Contains(response.Body.String(), `<img src=x`) || strings.Contains(response.Body.String(), `<script>番組</script>`) {
|
|
t.Fatal("確認画面でHTMLがエスケープされていない")
|
|
}
|
|
if name == "両方と重複除去" && (!strings.Contains(response.Body.String(), `<script>番組</script>`) || !strings.Contains(response.Body.String(), `<img src=x onerror=alert(1)>`)) {
|
|
t.Fatalf("番組名または回タイトルのエスケープ結果がない: %s", response.Body.String())
|
|
}
|
|
if name == "両方と重複除去" && strings.Count(response.Body.String(), `name="id"`) != 2 {
|
|
t.Fatalf("重複除去後のhidden ID: %s", response.Body.String())
|
|
}
|
|
})
|
|
}
|
|
for name, values := range map[string]url.Values{
|
|
"対象なし": {},
|
|
"該当なし": {"id": {"99"}},
|
|
"不正ID": {"id": {"bad"}},
|
|
"ゼロID": {"id": {"0"}},
|
|
"不正番組ID": {"series_id": {"bad-id"}},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
if got := postForm(handler, "/library/delete/confirm", values).Code; got != http.StatusBadRequest {
|
|
t.Fatalf("status=%d", got)
|
|
}
|
|
})
|
|
}
|
|
tooMany := url.Values{}
|
|
for i := 1; i <= maxDeleteSelections+1; i++ {
|
|
tooMany.Add("id", strconv.Itoa(i))
|
|
}
|
|
if got := postForm(handler, "/library/delete/confirm", tooMany).Code; got != http.StatusBadRequest {
|
|
t.Fatalf("上限超過 status=%d", got)
|
|
}
|
|
atLimit := url.Values{}
|
|
for i := 1; i <= maxDeleteSelections; i++ {
|
|
atLimit.Add("id", strconv.Itoa(i))
|
|
}
|
|
if response := postForm(handler, "/library/delete/confirm", atLimit); response.Code != http.StatusOK || len(jobs.jobs) != 3 {
|
|
t.Fatalf("入力上限ちょうど: status=%d jobs=%d", response.Code, len(jobs.jobs))
|
|
}
|
|
}
|
|
|
|
func TestDeleteConfirmationRejectsExpandedSelectionOverLimit(t *testing.T) {
|
|
makeJobs := func(count int) map[string]store.Job {
|
|
jobs := make(map[string]store.Job, count)
|
|
for i := 1; i <= count; i++ {
|
|
job := libraryJob(int64(i), "show/video.mp4")
|
|
job.EpisodeID = "ep" + strconv.Itoa(i)
|
|
jobs[job.EpisodeID] = job
|
|
}
|
|
return jobs
|
|
}
|
|
|
|
atLimit := &fakeJobStore{jobs: makeJobs(maxDeleteSelections)}
|
|
response := postForm(newTestHandlerWithVideoDir(&fakeFetcher{}, atLimit, t.TempDir(), nil),
|
|
"/library/delete/confirm", url.Values{"series_id": {"series1"}})
|
|
if response.Code != http.StatusOK || len(atLimit.jobs) != maxDeleteSelections {
|
|
t.Fatalf("上限ちょうど: status=%d jobs=%d", response.Code, len(atLimit.jobs))
|
|
}
|
|
|
|
overLimit := &fakeJobStore{jobs: makeJobs(maxDeleteSelections + 1)}
|
|
response = postForm(newTestHandlerWithVideoDir(&fakeFetcher{}, overLimit, t.TempDir(), nil),
|
|
"/library/delete/confirm", url.Values{"series_id": {"series1"}})
|
|
if response.Code != http.StatusBadRequest || len(overLimit.jobs) != maxDeleteSelections+1 ||
|
|
!strings.Contains(response.Body.String(), strconv.Itoa(maxDeleteSelections+1)+"本") ||
|
|
!strings.Contains(response.Body.String(), "番組を分けて選んでください") {
|
|
t.Fatalf("上限超過: status=%d jobs=%d body=%s", response.Code, len(overLimit.jobs), response.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestBulkDeleteContinuesAfterFailureAndDoesNotExpandSeries(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(dir, "show"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "show", "one.mp4"), []byte("one"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Mkdir(filepath.Join(dir, "show", "blocked"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "show", "blocked", "child"), []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first := libraryJob(1, "show/one.mp4")
|
|
blocked := libraryJob(2, "show/blocked")
|
|
blocked.EpisodeID = "ep2"
|
|
blocked.Title = "削除失敗回"
|
|
newAfterConfirmation := libraryJob(3, "show/new.mp4")
|
|
newAfterConfirmation.EpisodeID = "ep3"
|
|
newAfterConfirmation.Title = "確認後の新着"
|
|
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": first, "ep2": blocked, "ep3": newAfterConfirmation}}
|
|
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
|
|
|
response := postForm(handler, "/library/delete", url.Values{
|
|
"id": {"1", "2", "99"},
|
|
"series_id": {"series1"},
|
|
})
|
|
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "削除: 1件") || !strings.Contains(response.Body.String(), "失敗: 1件") || !strings.Contains(response.Body.String(), "対象外: 1件") {
|
|
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
|
}
|
|
if _, ok := jobs.jobs["ep1"]; ok {
|
|
t.Fatal("正常な回の記録が残った")
|
|
}
|
|
if _, ok := jobs.jobs["ep2"]; !ok {
|
|
t.Fatal("失敗した回の記録が消えた")
|
|
}
|
|
if _, ok := jobs.jobs["ep3"]; !ok {
|
|
t.Fatal("確認後に増えた回が消えた")
|
|
}
|
|
if strings.Contains(response.Body.String(), filepath.Join(dir, "show", "blocked")) {
|
|
t.Fatal("内部パスが結果画面に出た")
|
|
}
|
|
for _, detail := range []string{"directory not empty", "show/blocked"} {
|
|
if strings.Contains(response.Body.String(), detail) {
|
|
t.Fatalf("エラー詳細が結果画面に出た: %q", detail)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBulkDeleteWhenVideoRootCannotOpenDeletesOnlyRecordWithoutFile(t *testing.T) {
|
|
withFile := libraryJob(1, "show/video.mp4")
|
|
withFile.Title = `<img src=x onerror=alert(1)>`
|
|
withoutFile := libraryJob(2, "")
|
|
withoutFile.EpisodeID = "ep2"
|
|
withoutFile.Title = "記録だけの回"
|
|
withoutFile.FilePath = nil
|
|
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": withFile, "ep2": withoutFile}}
|
|
missingRoot := filepath.Join(t.TempDir(), "missing-video-root")
|
|
|
|
response := postForm(newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, missingRoot, nil),
|
|
"/library/delete", url.Values{"id": {"1", "2"}})
|
|
if response.Code != http.StatusOK ||
|
|
!strings.Contains(response.Body.String(), "削除: 1件") ||
|
|
!strings.Contains(response.Body.String(), "失敗: 1件") {
|
|
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
|
}
|
|
if _, ok := jobs.jobs["ep1"]; !ok {
|
|
t.Fatal("動画ルートを開けない回の記録が消えた")
|
|
}
|
|
if _, ok := jobs.jobs["ep2"]; ok {
|
|
t.Fatal("file_pathがNULLの回の記録が残った")
|
|
}
|
|
for _, secret := range []string{missingRoot, "no such file or directory", `<img src=x`} {
|
|
if strings.Contains(response.Body.String(), secret) {
|
|
t.Fatalf("内部情報または未エスケープHTMLが結果画面に出た: %q", secret)
|
|
}
|
|
}
|
|
if !strings.Contains(response.Body.String(), `<img src=x onerror=alert(1)>`) {
|
|
t.Fatalf("失敗した回のタイトルが安全に表示されていない: %s", response.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestBulkDeleteRejectsEmptySelection(t *testing.T) {
|
|
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "show/video.mp4")}}
|
|
response := postForm(newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, t.TempDir(), nil), "/library/delete", nil)
|
|
if response.Code != http.StatusBadRequest || len(jobs.jobs) != 1 {
|
|
t.Fatalf("status=%d jobs=%d", response.Code, len(jobs.jobs))
|
|
}
|
|
}
|
|
|
|
func TestLibraryDeleteEntryFormsAndOldRoute(t *testing.T) {
|
|
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "show/video.mp4")}}
|
|
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, t.TempDir(), nil)
|
|
for _, path := range []string{"/library", "/library/series/series1", "/library/1"} {
|
|
response := getPage(handler, path)
|
|
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `action="/library/delete/confirm"`) {
|
|
t.Fatalf("%s: status=%d body=%s", path, response.Code, response.Body.String())
|
|
}
|
|
}
|
|
if body := getPage(handler, "/library/1").Body.String(); strings.Contains(body, "hx-confirm") {
|
|
t.Fatal("再生ページにhx-confirmが残っている")
|
|
}
|
|
if got := postForm(handler, "/library/1/delete", nil).Code; got != http.StatusNotFound && got != http.StatusMethodNotAllowed {
|
|
t.Fatalf("旧経路 status=%d", got)
|
|
}
|
|
}
|