Files
tver/internal/web/subscription_url_test.go

261 lines
14 KiB
Go

package web
import (
"bytes"
"errors"
"log"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"gitea.kazu634.com/kazu634/tver/internal/store"
"gitea.kazu634.com/kazu634/tver/internal/tver"
)
func TestParseSeriesURL(t *testing.T) {
tests := []struct {
name, raw, want string
valid bool
}{
{"without www", "https://tver.jp/series/sr123", "sr123", true},
{"with www", "https://www.tver.jp/series/ABC123", "ABC123", true},
{"uppercase host", "https://TVER.JP/series/sr123", "sr123", true},
{"trailing slash", "https://tver.jp/series/sr123/", "sr123", true},
{"query and fragment", "https://tver.jp/series/sr123/?foo=bar#section", "sr123", true},
{"surrounding whitespace", " \thttps://tver.jp/series/sr123\n", "sr123", true},
{"http", "http://tver.jp/series/sr123", "", false},
{"subdomain suffix", "https://tver.jp.example.com/series/sr123", "", false},
{"evil prefix", "https://eviltver.jp/series/sr123", "", false},
{"userinfo on allowed host", "https://evil@tver.jp/series/sr123", "", false},
{"allowed host as userinfo", "https://tver.jp@evil.example/series/sr123", "", false},
{"episode", "https://tver.jp/episodes/ep123", "", false},
{"lp", "https://tver.jp/lp/example", "", false},
{"corner", "https://tver.jp/corner/example", "", false},
{"feature", "https://tver.jp/feature/example", "", false},
{"missing id", "https://tver.jp/series/", "", false},
{"invalid id", "https://tver.jp/series/sr_123", "", false},
{"extra path", "https://tver.jp/series/sr123/episodes", "", false},
{"encoded slash in id", "https://tver.jp/series/sr123%2Fevil", "", false},
{"encoded dot segments", "https://tver.jp/series/%2E%2E/episodes", "", false},
{"duplicate slash", "https://tver.jp//series/sr123", "", false},
{"port", "https://tver.jp:443/series/sr123", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseSeriesURL(tt.raw)
if tt.valid {
if err != nil || got != tt.want {
t.Fatalf("parseSeriesURL(%q)=%q, %v; want %q", tt.raw, got, err, tt.want)
}
return
}
if err == nil || err.Error() != seriesURLInputError {
t.Fatalf("parseSeriesURL(%q)=%q, %v; want input error", tt.raw, got, err)
}
})
}
}
func TestSubscriptionsPageShowsSeriesURLForm(t *testing.T) {
page := getPage(newTestHandlerWithStore(&fakeFetcher{}, &fakeJobStore{}, nil), "/subscriptions")
body := page.Body.String()
if page.Code != http.StatusOK || !strings.Contains(body, `name="series_url"`) || !strings.Contains(body, `hx-post="/subscriptions/from-url/confirm"`) || !strings.Contains(body, `id="subscriptions-list"`) {
t.Fatalf("URL入力フォーム: status=%d body=%s", page.Code, body)
}
}
func TestConfirmSubscriptionURL(t *testing.T) {
fetcher := &fakeFetcher{
seriesTitle: `<script>alert("title")</script>`,
seriesEpisodes: []tver.Episode{
{ID: "ep1", SeriesID: "sr1"},
{ID: "ep2", SeriesID: "sr1"},
},
}
handler := newTestHandlerWithStore(fetcher, &fakeJobStore{}, nil)
response := postForm(handler, "/subscriptions/from-url/confirm", url.Values{"series_url": {"https://tver.jp/series/sr1?x=1#fragment"}})
body := response.Body.String()
for _, want := range []string{"&lt;script&gt;", "番組ID: sr1", "公開中の回数: 2件", `name="series_id" value="sr1"`, "公開中の回もダウンロードする", ">登録</button>", ">キャンセル</button>"} {
if !strings.Contains(body, want) {
t.Errorf("確認画面に %q がない: %s", want, body)
}
}
if response.Code != http.StatusOK || strings.Contains(body, `<script>`) || strings.Contains(body, `name="series_title"`) || strings.Contains(body, `checked`) {
t.Fatalf("確認画面: status=%d body=%s", response.Code, body)
}
if len(fetcher.seriesTitleCalls) != 1 || fetcher.seriesTitleCalls[0] != "sr1" || len(fetcher.seriesCalls) != 1 || fetcher.seriesCalls[0] != "sr1" {
t.Fatalf("取得したID: title=%v episodes=%v", fetcher.seriesTitleCalls, fetcher.seriesCalls)
}
}
func TestConfirmSubscriptionURLAlreadyRegisteredIncludingPaused(t *testing.T) {
fetcher := &fakeFetcher{seriesTitle: "番組", seriesEpisodes: []tver.Episode{{ID: "ep1", SeriesID: "sr1"}}}
jobs := &fakeJobStore{subscriptions: map[string]store.Subscription{"sr1": {SeriesID: "sr1", SeriesTitle: "番組", Enabled: false}}}
response := postForm(newTestHandlerWithStore(fetcher, jobs, nil), "/subscriptions/from-url/confirm", url.Values{"series_url": {"https://tver.jp/series/sr1"}})
body := response.Body.String()
if response.Code != http.StatusOK || !strings.Contains(body, "番組") || !strings.Contains(body, "公開中の回数: 1件") || !strings.Contains(body, "登録済み") || strings.Contains(body, ">登録</button>") || len(fetcher.seriesTitleCalls) != 1 || len(fetcher.seriesCalls) != 1 {
t.Fatalf("登録済み確認: status=%d body=%s title=%v episodes=%v", response.Code, body, fetcher.seriesTitleCalls, fetcher.seriesCalls)
}
}
func TestSubscriptionURLNotFoundAndInvalidURL(t *testing.T) {
fetcher := &fakeFetcher{seriesTitleErr: tver.ErrSeriesNotFound}
jobs := &fakeJobStore{}
handler := newTestHandlerWithStore(fetcher, jobs, nil)
notFound := postForm(handler, "/subscriptions/from-url/confirm", url.Values{"series_url": {"https://tver.jp/series/missing"}})
if notFound.Code != http.StatusOK || !strings.Contains(notFound.Body.String(), "番組が見つかりません") || len(fetcher.seriesCalls) != 0 {
t.Fatalf("404: status=%d body=%s episodeCalls=%v", notFound.Code, notFound.Body.String(), fetcher.seriesCalls)
}
registerNotFound := postForm(handler, "/subscriptions/from-url/register", url.Values{"series_id": {"missing"}})
if registerNotFound.Code != http.StatusOK || !strings.Contains(registerNotFound.Body.String(), "番組が見つかりません") || jobs.registerCalls != 0 {
t.Fatalf("登録時404: status=%d body=%s registerCalls=%d", registerNotFound.Code, registerNotFound.Body.String(), jobs.registerCalls)
}
invalid := postForm(handler, "/subscriptions/from-url/confirm", url.Values{"series_url": {"https://evil.example/series/sr1"}})
if invalid.Code != http.StatusOK || !strings.Contains(invalid.Body.String(), seriesURLInputError) || len(fetcher.seriesTitleCalls) != 2 {
t.Fatalf("不正URL: status=%d body=%s titleCalls=%v", invalid.Code, invalid.Body.String(), fetcher.seriesTitleCalls)
}
}
func TestRegisterSubscriptionURLWithAndWithoutExistingEpisodes(t *testing.T) {
for _, download := range []bool{false, true} {
t.Run(map[bool]string{false: "without existing", true: "with existing"}[download], func(t *testing.T) {
fetcher := &fakeFetcher{seriesTitle: "番組", seriesEpisodes: []tver.Episode{}}
jobs := &fakeJobStore{}
handler := newTestHandlerWithStore(fetcher, jobs, nil)
confirmation := postForm(handler, "/subscriptions/from-url/confirm", url.Values{"series_url": {"https://tver.jp/series/sr1"}})
if confirmation.Code != http.StatusOK || !strings.Contains(confirmation.Body.String(), "公開中の回はありません") {
t.Fatalf("0件の確認: %d %s", confirmation.Code, confirmation.Body.String())
}
values := url.Values{"series_id": {"sr1"}, "series_title": {"偽番組"}}
if download {
values.Set("download_existing", "1")
}
registered := postForm(handler, "/subscriptions/from-url/register", values)
body := registered.Body.String()
if registered.Code != http.StatusOK || !strings.Contains(body, "登録済み") || !strings.Contains(body, `id="subscriptions-list"`) || !strings.Contains(body, `hx-swap-oob="outerHTML"`) || !strings.Contains(body, `id="subscription-url-input"`) {
t.Fatalf("登録応答: %d %s", registered.Code, body)
}
if jobs.registerCalls != 1 || jobs.registerDownload != download || len(jobs.registerEpisodes) != 0 || jobs.subscriptions["sr1"].SeriesTitle != "番組" {
t.Fatalf("登録内容: calls=%d download=%t episodes=%v subscriptions=%v", jobs.registerCalls, jobs.registerDownload, jobs.registerEpisodes, jobs.subscriptions)
}
if len(fetcher.seriesTitleCalls) != 2 || len(fetcher.seriesCalls) != 2 {
t.Fatalf("確認と登録で再取得していない: title=%v episodes=%v", fetcher.seriesTitleCalls, fetcher.seriesCalls)
}
})
}
}
func TestRegisterSubscriptionURLUsesServerFetchedData(t *testing.T) {
fetcher := &fakeFetcher{seriesTitle: "本当の番組", seriesEpisodes: []tver.Episode{{
ID: "ep1", SeriesID: "sr1", SeriesTitle: "本当の番組", Title: "公開中の回", Duration: 200,
}}}
jobs := &fakeJobStore{}
handler := newTestHandlerWithStore(fetcher, jobs, nil)
response := postForm(handler, "/subscriptions/from-url/register", url.Values{
"series_id": {"sr1"}, "series_title": {"偽番組"}, "download_existing": {"1"},
})
if response.Code != http.StatusOK || jobs.registerCalls != 1 || !jobs.registerDownload || len(jobs.registerEpisodes) != 1 || jobs.registerEpisodes[0].ID != "ep1" || jobs.subscriptions["sr1"].SeriesTitle != "本当の番組" {
t.Fatalf("サーバー取得値での登録: status=%d calls=%d download=%t episodes=%+v subscriptions=%+v", response.Code, jobs.registerCalls, jobs.registerDownload, jobs.registerEpisodes, jobs.subscriptions)
}
}
func TestRegisterSubscriptionURLAlreadyRegisteredDoesNothing(t *testing.T) {
fetcher := &fakeFetcher{seriesTitleErr: errors.New("must not fetch"), seriesErr: errors.New("must not fetch")}
jobs := &fakeJobStore{subscriptions: map[string]store.Subscription{"sr1": {SeriesID: "sr1", SeriesTitle: "番組", Enabled: false}}}
response := postForm(newTestHandlerWithStore(fetcher, jobs, nil), "/subscriptions/from-url/register", url.Values{"series_id": {"sr1"}})
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "登録済み") || jobs.registerCalls != 0 || len(fetcher.seriesTitleCalls) != 0 || len(fetcher.seriesCalls) != 0 {
t.Fatalf("登録済みの再登録: status=%d body=%s register=%d title=%v episodes=%v", response.Code, response.Body.String(), jobs.registerCalls, fetcher.seriesTitleCalls, fetcher.seriesCalls)
}
}
func TestRegisterSubscriptionURLFetchFailuresDoNotRegisterAndRedactToken(t *testing.T) {
for _, tt := range []struct {
name string
seriesErr error
episodesErr error
wantEpisodeCall int
}{
{"series", errors.New("platform_token=secret-series& upstream"), nil, 0},
{"episodes", nil, errors.New("platform_token=secret-episodes& upstream"), 1},
} {
t.Run(tt.name, func(t *testing.T) {
fetcher := &fakeFetcher{seriesTitle: "番組", seriesTitleErr: tt.seriesErr, seriesErr: tt.episodesErr}
jobs := &fakeJobStore{}
var logs bytes.Buffer
handler := NewHandler(fetcher, jobs, "", time.Now, log.New(&logs, "", 0))
response := postForm(handler, "/subscriptions/from-url/register", url.Values{"series_id": {"sr1"}})
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "番組情報を取得できませんでした。再試行してください") || jobs.registerCalls != 0 || len(jobs.subscriptions) != 0 {
t.Fatalf("取得失敗: status=%d body=%s calls=%d subscriptions=%v", response.Code, response.Body.String(), jobs.registerCalls, jobs.subscriptions)
}
if strings.Contains(logs.String(), "secret-") || !strings.Contains(logs.String(), "platform_token=[REDACTED]") || len(fetcher.seriesCalls) != tt.wantEpisodeCall {
t.Fatalf("ログまたは呼出回数: logs=%s episodeCalls=%v", logs.String(), fetcher.seriesCalls)
}
})
}
}
func TestRegisterSubscriptionURLRejectsInvalidChoice(t *testing.T) {
fetcher := &fakeFetcher{}
jobs := &fakeJobStore{}
handler := newTestHandlerWithStore(fetcher, jobs, nil)
for _, values := range []url.Values{
{},
{"series_id": {"bad_id"}},
{"series_id": {"../sr1"}},
{"series_id": {"sr1"}, "download_existing": {"yes"}},
} {
response := postForm(handler, "/subscriptions/from-url/register", values)
if response.Code != http.StatusBadRequest {
t.Errorf("不正値 %v: status=%d body=%s", values, response.Code, response.Body.String())
}
}
if len(fetcher.seriesTitleCalls) != 0 || len(fetcher.seriesCalls) != 0 || jobs.subscribedCalls != 0 || jobs.registerCalls != 0 {
t.Fatalf("不正な hidden 値で外部取得または DB 操作を実行: title=%v episodes=%v subscribed=%d register=%d", fetcher.seriesTitleCalls, fetcher.seriesCalls, jobs.subscribedCalls, jobs.registerCalls)
}
}
func TestCancelSubscriptionURLClearsConfirmation(t *testing.T) {
response := getPage(newTestHandlerWithStore(&fakeFetcher{}, &fakeJobStore{}, nil), "/subscriptions/from-url/cancel")
body := response.Body.String()
if response.Code != http.StatusOK || !strings.Contains(body, `id="subscription-url-result"`) || strings.Contains(body, `name="series_id"`) || strings.Contains(body, `role="alert"`) || strings.Contains(body, `role="status"`) {
t.Fatalf("キャンセル応答: status=%d body=%q", response.Code, response.Body.String())
}
}
func TestSubscriptionURLPostsWithoutHtmxRedirect(t *testing.T) {
fetcher := &fakeFetcher{seriesTitle: "番組", seriesEpisodes: []tver.Episode{}}
jobs := &fakeJobStore{}
handler := newTestHandlerWithStore(fetcher, jobs, nil)
for _, tt := range []struct {
path string
form url.Values
}{
{"/subscriptions/from-url/confirm", url.Values{"series_url": {"https://tver.jp/series/sr1"}}},
{"/subscriptions/from-url/register", url.Values{"series_id": {"sr1"}}},
} {
request := httptest.NewRequest(http.MethodPost, tt.path, strings.NewReader(tt.form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/subscriptions" {
t.Errorf("通常POST %s: status=%d Location=%q", tt.path, response.Code, response.Header().Get("Location"))
}
}
if len(fetcher.seriesTitleCalls) != 1 || jobs.registerCalls != 1 {
t.Fatalf("登録POSTが処理されていない: fetch=%v register=%d", fetcher.seriesTitleCalls, jobs.registerCalls)
}
}
func TestSubscriptionURLPostRoutesRejectGet(t *testing.T) {
handler := newTestHandlerWithStore(&fakeFetcher{}, &fakeJobStore{}, nil)
for _, path := range []string{"/subscriptions/from-url/confirm", "/subscriptions/from-url/register"} {
response := getPage(handler, path)
if response.Code != http.StatusMethodNotAllowed || !strings.Contains(response.Header().Get("Allow"), "POST") {
t.Errorf("GET %s: status=%d Allow=%q", path, response.Code, response.Header().Get("Allow"))
}
}
}