317 lines
14 KiB
Go
317 lines
14 KiB
Go
package webserver
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"readeckobo/internal/app"
|
|
"readeckobo/internal/config"
|
|
"readeckobo/internal/logger"
|
|
)
|
|
|
|
func TestBookSyncRoutes(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name, path, scheme, host, upstreamPath, escapedPath, query string
|
|
wantStatus int
|
|
}{
|
|
{"custom endpoint with path prefix", "/booksync/books/v1/library/sync?page=2", "https", "books.example.com", "/api/kobo/device/v1/library/sync", "/api/kobo/device/v1/library/sync", "page=2", http.StatusAccepted},
|
|
{"custom HTTP endpoint", "/booksync/httpbook/v1/library/sync", "http", "localhost:8081", "/kobo/v1/library/sync", "/kobo/v1/library/sync", "", http.StatusAccepted},
|
|
{"escaped suffix", "/booksync/books/v1/%2Fbooks?a=1%3B2", "https", "books.example.com", "/api/kobo/device/v1//books", "/api/kobo/device/v1/%2Fbooks", "a=1%3B2", http.StatusAccepted},
|
|
{"custom endpoint root", "/booksync/books/", "https", "books.example.com", "/api/kobo/device/", "/api/kobo/device/", "", http.StatusAccepted},
|
|
{"known token uses Kobo Store", "/booksync/store/v1/library/sync?page=3", "https", "storeapi.kobo.com", "/v1/library/sync", "/v1/library/sync", "page=3", http.StatusAccepted},
|
|
{"unknown token", "/booksync/unknown/v1/library/sync", "", "", "", "", "", http.StatusNotFound},
|
|
{"partial token does not match", "/booksync/book/v1/library/sync", "", "", "", "", "", http.StatusNotFound},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var calls int
|
|
application := app.NewApp(
|
|
app.WithConfig(&config.Config{Users: []config.User{
|
|
{Token: "books", BookSyncURL: "https://books.example.com/api/kobo/device"},
|
|
{Token: "httpbook", BookSyncURL: "http://localhost:8081/kobo"},
|
|
{Token: "store"},
|
|
}}),
|
|
app.WithLogger(logger.New(logger.ERROR)),
|
|
app.WithProxyTransport(roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
calls++
|
|
if req.URL.Scheme != tt.scheme || req.URL.Host != tt.host || req.Host != tt.host || req.URL.Path != tt.upstreamPath || req.URL.EscapedPath() != tt.escapedPath || req.URL.RawQuery != tt.query {
|
|
t.Errorf("upstream URL = %s, Host = %q; want %s://%s, path %q, escaped %q, query %q", req.URL, req.Host, tt.scheme, tt.host, tt.upstreamPath, tt.escapedPath, tt.query)
|
|
}
|
|
if req.Method != http.MethodPost || req.Header.Get("X-Test") != "forwarded" {
|
|
t.Errorf("upstream method = %s, X-Test = %q", req.Method, req.Header.Get("X-Test"))
|
|
}
|
|
body, err := io.ReadAll(req.Body)
|
|
if err != nil || string(body) != "payload" {
|
|
t.Errorf("upstream body = %q, err = %v", body, err)
|
|
}
|
|
return &http.Response{StatusCode: http.StatusAccepted, Header: http.Header{}, Body: io.NopCloser(strings.NewReader("book response"))}, nil
|
|
})),
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, tt.path, strings.NewReader("payload"))
|
|
req.Header.Set("X-Test", "forwarded")
|
|
NewHandler(application, logger.New(logger.ERROR)).ServeHTTP(rr, req)
|
|
if rr.Code != tt.wantStatus {
|
|
t.Errorf("status = %d, want %d", rr.Code, tt.wantStatus)
|
|
}
|
|
if tt.host == "" {
|
|
if calls != 0 {
|
|
t.Errorf("unknown token reached upstream (%d calls)", calls)
|
|
}
|
|
} else if calls != 1 || rr.Body.String() != "book response" {
|
|
t.Errorf("upstream calls = %d, body = %q; want one call and book response", calls, rr.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBookSyncDoesNotRetryConfiguredService(t *testing.T) {
|
|
var calls int
|
|
application := app.NewApp(
|
|
app.WithConfig(&config.Config{Users: []config.User{{Token: "books", BookSyncURL: "https://books.example.com/api/kobo/device"}}}),
|
|
app.WithLogger(logger.New(logger.ERROR)),
|
|
app.WithProxyTransport(roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
calls++
|
|
if req.URL.Host != "books.example.com" {
|
|
t.Errorf("upstream host = %q, want books.example.com", req.URL.Host)
|
|
}
|
|
return nil, errors.New("book service unavailable")
|
|
})),
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
NewHandler(application, logger.New(logger.ERROR)).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/booksync/books/v1/library/sync", nil))
|
|
if rr.Code != http.StatusBadGateway || calls != 1 {
|
|
t.Errorf("status = %d, upstream calls = %d; want 502 and one call", rr.Code, calls)
|
|
}
|
|
}
|
|
|
|
func TestBookSyncInitialization(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name, token, upstreamHost, upstreamPath string
|
|
gzip bool
|
|
}{
|
|
{"custom plaintext", "books", "books.example.com", "/api/kobo/device/v1/initialization", false},
|
|
{"custom gzip", "books", "books.example.com", "/api/kobo/device/v1/initialization", true},
|
|
{"store fallback", "store", "storeapi.kobo.com", "/v1/initialization", false},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
body := []byte(`{"instapaper":"https://www.instapaper.com"}`)
|
|
header := http.Header{"Content-Type": {"application/json"}, "Etag": {`"old"`}}
|
|
if tt.gzip {
|
|
var b bytes.Buffer
|
|
writer := gzip.NewWriter(&b)
|
|
if _, err := writer.Write(body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body = b.Bytes()
|
|
header.Set("Content-Encoding", "gzip")
|
|
}
|
|
application := app.NewApp(
|
|
app.WithConfig(&config.Config{PublicURL: "https://reader.example.com", Users: []config.User{
|
|
{Token: "books", BookSyncURL: "https://books.example.com/api/kobo/device"}, {Token: "store"},
|
|
}}),
|
|
app.WithLogger(logger.New(logger.ERROR)),
|
|
app.WithProxyTransport(roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
if req.URL.Host != tt.upstreamHost || req.URL.Path != tt.upstreamPath || req.URL.RawQuery != "locale=en" || req.Header.Get("Accept-Encoding") != "identity" || req.Header.Get("Range") != "" || req.Header.Get("If-None-Match") != "" {
|
|
t.Errorf("unexpected initialization upstream: URL=%s, headers=%v", req.URL, req.Header)
|
|
}
|
|
return &http.Response{StatusCode: http.StatusOK, Header: header, Body: io.NopCloser(bytes.NewReader(body))}, nil
|
|
})),
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/booksync/"+tt.token+"/v1/initialization?locale=en", nil)
|
|
req.Header.Set("If-None-Match", `"old"`)
|
|
req.Header.Set("Range", "bytes=0-10")
|
|
NewHandler(application, logger.New(logger.ERROR)).ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK || rr.Header().Get("ETag") != "" {
|
|
t.Fatalf("status = %d, headers = %v", rr.Code, rr.Header())
|
|
}
|
|
if got := rr.Header().Get("Content-Length"); got != strconv.Itoa(rr.Body.Len()) {
|
|
t.Errorf("Content-Length = %q, body size = %d", got, rr.Body.Len())
|
|
}
|
|
var result []byte
|
|
var err error
|
|
if tt.gzip {
|
|
if rr.Header().Get("Content-Encoding") != "gzip" {
|
|
t.Errorf("Content-Encoding = %q, want gzip", rr.Header().Get("Content-Encoding"))
|
|
}
|
|
reader, gzipErr := gzip.NewReader(rr.Body)
|
|
if gzipErr != nil {
|
|
t.Fatal(gzipErr)
|
|
}
|
|
result, err = io.ReadAll(reader)
|
|
_ = reader.Close()
|
|
} else {
|
|
result = rr.Body.Bytes()
|
|
}
|
|
if err != nil || string(result) != `{"instapaper":"https://reader.example.com/instapaper-proxy/instapaper"}` {
|
|
t.Errorf("rewritten body = %q, err = %v", result, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBookSyncInitializationPreservesBookResourcesPerUser(t *testing.T) {
|
|
for _, tt := range []struct{ token, host, key string }{
|
|
{"alice", "books-alice.example.com", "alice-key"},
|
|
{"bob", "books-bob.example.com", "bob-key"},
|
|
} {
|
|
t.Run(tt.token, func(t *testing.T) {
|
|
upstream := "https://" + tt.host + "/kobo/" + tt.key
|
|
body := `{"Resources":{"instapaper_env_url":"https://www.instapaper.com/api/kobo",` +
|
|
`"image_host":"https://` + tt.host + `",` +
|
|
`"image_url_template":"` + upstream + `/v1/books/{ImageId}/thumbnail/{Width}/{Height}/false/image.jpg",` +
|
|
`"image_url_quality_template":"` + upstream + `/v1/books/{ImageId}/thumbnail/{Width}/{Height}/{Quality}/{IsGreyscale}/image.jpg",` +
|
|
`"other_resource":"https://cdn.kobo.com/book.jpg"}}`
|
|
application := app.NewApp(
|
|
app.WithConfig(&config.Config{PublicURL: "https://reader.example.com", Users: []config.User{
|
|
{Token: "alice", BookSyncURL: "https://books-alice.example.com/kobo/alice-key"},
|
|
{Token: "bob", BookSyncURL: "https://books-bob.example.com/kobo/bob-key"},
|
|
}}),
|
|
app.WithLogger(logger.New(logger.ERROR)),
|
|
app.WithProxyTransport(roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
if req.URL.Host != tt.host || req.URL.Path != "/kobo/"+tt.key+"/v1/initialization" {
|
|
t.Errorf("upstream URL = %s, want %s/v1/initialization", req.URL, upstream)
|
|
}
|
|
if req.Header.Get("X-Forwarded-Host") != tt.host || req.Header.Get("X-Forwarded-Proto") != "https" {
|
|
t.Errorf("forwarded host/proto = %q/%q; want %s/https", req.Header.Get("X-Forwarded-Host"), req.Header.Get("X-Forwarded-Proto"), tt.host)
|
|
}
|
|
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}, nil
|
|
})),
|
|
)
|
|
req := httptest.NewRequest(http.MethodGet, "/booksync/"+tt.token+"/v1/initialization", nil)
|
|
req.Header.Set("X-Forwarded-Host", "untrusted.example.com")
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
req.Header.Set("X-Forwarded-Prefix", "/wrong")
|
|
rr := httptest.NewRecorder()
|
|
NewHandler(application, logger.New(logger.ERROR)).ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
var result struct {
|
|
Resources map[string]string
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for key, want := range map[string]string{
|
|
"instapaper_env_url": "https://reader.example.com/instapaper-proxy/instapaper/api/kobo",
|
|
"image_host": "https://" + tt.host,
|
|
"image_url_template": upstream + "/v1/books/{ImageId}/thumbnail/{Width}/{Height}/false/image.jpg",
|
|
"image_url_quality_template": upstream + "/v1/books/{ImageId}/thumbnail/{Width}/{Height}/{Quality}/{IsGreyscale}/image.jpg",
|
|
"other_resource": "https://cdn.kobo.com/book.jpg",
|
|
} {
|
|
if got := result.Resources[key]; got != want {
|
|
t.Errorf("Resources[%s] = %q, want %q", key, got, want)
|
|
}
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "/kobo/"+tt.key+"/v1/books/") {
|
|
t.Error("book service cover URL was not preserved")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBookSyncLeavesOtherResponsesUntouched(t *testing.T) {
|
|
for _, tt := range []struct{ token, host, prefix string }{
|
|
{"alice", "books-alice.example.com", "/kobo/alice-key"},
|
|
{"bob", "books-bob.example.com", "/kobo/bob-key"},
|
|
{"store", "storeapi.kobo.com", ""},
|
|
} {
|
|
t.Run(tt.token, func(t *testing.T) {
|
|
link := "https://" + tt.host + tt.prefix + "/v1/books/book-1/file/epub?convert_kepub=true"
|
|
metadata := `{"EntitlementId":"book-1","CoverImageId":"cover-1","DownloadUrls":[{"Format":"KEPUB","Url":"` + link + `"}]}`
|
|
application := app.NewApp(
|
|
app.WithConfig(&config.Config{PublicURL: "https://reader.example.com", Users: []config.User{
|
|
{Token: "alice", BookSyncURL: "https://books-alice.example.com/kobo/alice-key"},
|
|
{Token: "bob", BookSyncURL: "https://books-bob.example.com/kobo/bob-key"},
|
|
{Token: "store"},
|
|
}}),
|
|
app.WithLogger(logger.New(logger.ERROR)),
|
|
app.WithProxyTransport(roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
if req.URL.Host != tt.host || req.Header.Get("If-None-Match") != `"old"` || req.Header.Get("Range") != "bytes=0-10" {
|
|
t.Errorf("upstream URL = %s, headers = %v", req.URL, req.Header)
|
|
}
|
|
if !strings.HasPrefix(req.URL.Path, tt.prefix+"/v1/library/") {
|
|
t.Errorf("upstream path = %q, want prefix %s/v1/library/", req.URL.Path, tt.prefix)
|
|
}
|
|
body := "[" + metadata + "]"
|
|
if strings.HasSuffix(req.URL.Path, "/sync") {
|
|
body = `[{"NewEntitlement":{"BookMetadata":` + metadata + `}}]`
|
|
}
|
|
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}, "Etag": {`"old"`}}, Body: io.NopCloser(strings.NewReader(body))}, nil
|
|
})),
|
|
)
|
|
handler := NewHandler(application, logger.New(logger.ERROR))
|
|
for _, path := range []string{"/v1/library/sync", "/v1/library/book-1/metadata"} {
|
|
req := httptest.NewRequest(http.MethodGet, "/booksync/"+tt.token+path, nil)
|
|
req.Header.Set("If-None-Match", `"old"`)
|
|
req.Header.Set("Range", "bytes=0-10")
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
wantBody := "[" + metadata + "]"
|
|
if path == "/v1/library/sync" {
|
|
wantBody = `[{"NewEntitlement":{"BookMetadata":` + metadata + `}}]`
|
|
}
|
|
if rr.Code != http.StatusOK || rr.Header().Get("Etag") != `"old"` || rr.Body.String() != wantBody {
|
|
t.Errorf("%s: status = %d, headers = %v, body = %s; want %s", path, rr.Code, rr.Header(), rr.Body.String(), wantBody)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBookSyncStreamsDownload(t *testing.T) {
|
|
reader, writer := io.Pipe()
|
|
application := app.NewApp(
|
|
app.WithConfig(&config.Config{Users: []config.User{{Token: "books", BookSyncURL: "https://books.example.com/api/kobo/device"}}}),
|
|
app.WithLogger(logger.New(logger.ERROR)),
|
|
app.WithProxyTransport(roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
if req.URL.Path != "/api/kobo/device/v1/books/1/download" {
|
|
t.Errorf("upstream path = %q", req.URL.Path)
|
|
}
|
|
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/epub+zip"}}, Body: reader, ContentLength: -1}, nil
|
|
})),
|
|
)
|
|
server := httptest.NewServer(NewHandler(application, logger.New(logger.ERROR)))
|
|
defer server.Close()
|
|
defer func() { _ = writer.Close() }()
|
|
|
|
firstChunk := bytes.Repeat([]byte("x"), 32*1024)
|
|
result := make(chan error, 1)
|
|
go func() {
|
|
resp, err := server.Client().Get(server.URL + "/booksync/books/v1/books/1/download")
|
|
if err != nil {
|
|
result <- err
|
|
return
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
data := make([]byte, len(firstChunk))
|
|
_, err = io.ReadFull(resp.Body, data)
|
|
if err == nil && !bytes.Equal(data, firstChunk) {
|
|
err = io.ErrUnexpectedEOF
|
|
}
|
|
result <- err
|
|
}()
|
|
go func() { _, _ = writer.Write(firstChunk) }()
|
|
select {
|
|
case err := <-result:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("book download did not stream before upstream completion")
|
|
}
|
|
}
|