* Serve the build-independent web assets from the backend `privacy.html`, `markdown-help.html` and the `400x400.jpeg` it embeds carry no template variable, link no script or stylesheet, and are imported by nothing in the widget. They now live in `backend/app/webassets/assets`, embedded there, and are served under `/web` alongside the frontend build. `/web` reads the frontend build first and falls back to them, which is what lets an operator replace one by dropping a file into `--web-root`. That is what `privacy.html` needs: it describes remark42.com, while the authorization guide tells operators to hand its URL to Google and Facebook as their own application's privacy policy. Only a missing file falls through. An unreadable file in the web root keeps reporting as unreadable rather than being silently replaced by the embedded copy, and a name the filesystem rejects reports as missing rather than as a server error, both matching what `http.Dir` did. The dev server serves the same directory, so the Markdown help link in the comment form resolves on the dev port as well as in production. The two pages are served as they are written. `markdown-help.html` was minified before, and its formatted inline stylesheet is most of its 8.5 kB; that is 2.4 kB more over the wire, behind the hour-long cache header the file server already sets. Drops `copy-webpack-plugin`, which had no other pattern, and the stylelint entries that only ever matched these files. * Make pnpm dev:app start again The dev server has been failing to start on two counts, so the flow the contributing guide documents does not run at all. `webpack-cli` 4 drives `webpack-dev-server` 5 through the argument order of an older major, handing it the compiler where it expects the options object. It rejects that against its schema and exits, complaining about an unknown `_assetEmittingPreviousFiles` property, which is a field of the compiler. `webpack-cli` 7 is the release that declares `webpack-dev-server` 5 as a peer. Past that, `http-proxy-middleware` resolves to 4.1.1, which no longer accepts the two-argument call `webpack-dev-server` makes, so the `/api` and `/auth` proxies throw on startup. It is pulled in by the security override for CVE-2025-32996, the only override in the file with no upper bound: `>=2.0.10` matches every later major. Bounding it to the 2.x line keeps the fix and the API `webpack-dev-server` calls. With both in place `pnpm dev:app` serves the widget and the pages under `/web` on port 9000.
854 lines
31 KiB
Go
854 lines
31 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"math/rand"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"github.com/go-pkgz/auth/v2"
|
|
"github.com/go-pkgz/auth/v2/avatar"
|
|
"github.com/go-pkgz/auth/v2/provider"
|
|
"github.com/go-pkgz/auth/v2/token"
|
|
cache "github.com/go-pkgz/lcw/v2"
|
|
R "github.com/go-pkgz/rest"
|
|
"github.com/go-pkgz/routegroup"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
bolt "go.etcd.io/bbolt"
|
|
"go.uber.org/goleak"
|
|
|
|
"github.com/umputun/remark42/backend/app/migrator"
|
|
"github.com/umputun/remark42/backend/app/notify"
|
|
"github.com/umputun/remark42/backend/app/rest"
|
|
"github.com/umputun/remark42/backend/app/rest/proxy"
|
|
"github.com/umputun/remark42/backend/app/store"
|
|
adminstore "github.com/umputun/remark42/backend/app/store/admin"
|
|
"github.com/umputun/remark42/backend/app/store/engine"
|
|
"github.com/umputun/remark42/backend/app/store/image"
|
|
"github.com/umputun/remark42/backend/app/store/service"
|
|
"github.com/umputun/remark42/backend/app/webassets"
|
|
)
|
|
|
|
// To generate a token, enter one of the tokens here into https://jwt.io, change the secret to one you're using in your test
|
|
// ("secret" in case of startupT), and alter the fields you want to be changed.
|
|
|
|
var devToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImRldmVsb3BlciBvbmUiLCJpZCI6InByb3ZpZGVyMV9kZXYiLCJwaWN0dXJlIjoiaHR0cDovL2V4YW1wbGUuY29tL3BpYy5wbmciLCJpcCI6IjEyNy4wLjAuMSIsImVtYWlsIjoibWVAZXhhbXBsZS5jb20ifX0.dirTS_ahSF6375sdO2iodm2K2UmRTzQNQMFiHuTQCVs`
|
|
|
|
var dev2Token = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImRldmVsb3BlciBvbmUiLCJpZCI6InByb3ZpZGVyMV9kZXYyIiwicGljdHVyZSI6Imh0dHA6Ly9leGFtcGxlLmNvbS9waWMucG5nIiwiaXAiOiIxMjcuMC4wLjEiLCJlbWFpbCI6Im1lQGV4YW1wbGUuY29tIn19.qsR_PupfjIq7uw0eAuyGV8nsUoMx9v541c9olnRInRQ`
|
|
|
|
var anonToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImFub255bW91cyB0ZXN0IHVzZXIiLCJpZCI6ImFub255bW91c190ZXN0X3VzZXIiLCJwaWN0dXJlIjoiaHR0cDovL2V4YW1wbGUuY29tL3BpYy5wbmciLCJpcCI6IjEyNy4wLjAuMSIsImVtYWlsIjoiYW5vbkBleGFtcGxlLmNvbSJ9fQ.gAae2WMxZNZE5ebVboptPEyQ7Nk6EQxciNnGJ_mPOuU`
|
|
|
|
var emailUserToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6Imdvb2RAZXhhbXBsZS5jb20gdGVzdCB1c2VyIiwiaWQiOiJlbWFpbF9mNWRmZTlkMmU2YmQ3NWZjNzRlYTVmYWJmMjczYjQ1YjViYWViMTk1IiwicGljdHVyZSI6Imh0dHA6Ly9leGFtcGxlLmNvbS9waWMucG5nIiwiaXAiOiIxMjcuMC4wLjEiLCJlbWFpbCI6Imdvb2RAZXhhbXBsZS5jb20ifX0.vH2HN1JpuXL8okTJq1A-zGHQ-l2ILcwxvDDEmu2zwks`
|
|
|
|
var devTokenBadAud = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0Ml9iYWQiLCJleHAiOjM3ODkxOTE4MjIsImp0aSI6InJhbmRvbSBpZCIsImlzcyI6InJlbWFyazQyIiwibmJmIjoxNTIxODg0MjIyLCJ1c2VyIjp7Im5hbWUiOiJkZXZlbG9wZXIgb25lIiwiaWQiOiJwcm92aWRlcjFfZGV2IiwicGljdHVyZSI6Imh0dHA6Ly9leGFtcGxlLmNvbS9waWMucG5nIiwiaXAiOiIxMjcuMC4wLjEiLCJlbWFpbCI6Im1lQGV4YW1wbGUuY29tIn19.X-lvnHvBz6VfEbVV4f-bjcZuLY5pYtvEansk_TQMrX8`
|
|
|
|
var adminUmputunToken = `eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6MTk1NDU5Nzk4MCwianRpIjoiOTdhMmUwYWM0ZGM3ZDVmNjkyNmQ1ZTg2MjBhY2VmOWE0MGMwIiwiaWF0IjoxNDU0NTk3NjgwLCJpc3MiOiJyZW1hcms0MiIsInVzZXIiOnsibmFtZSI6IlVtcHV0dW4iLCJpZCI6ImdpdGh1Yl9lZjBmNzA2YTciLCJwaWN0dXJlIjoiaHR0cHM6Ly9yZW1hcms0Mi5yYWRpby10LmNvbS9hcGkvdjEvYXZhdGFyL2NiNDJmZjQ5M2FkZTY5NmQ4OGEzYTU5MGYxMzZhZTllMzRkZTdjMWIuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.dZiOjWHguo9f42XCMooMcv4EmYFzifl_-LEvPZHCtks`
|
|
|
|
func TestRest_FileServer(t *testing.T) {
|
|
ts, _, teardown := startupT(t)
|
|
defer teardown()
|
|
|
|
testHTMLName := "test-remark.html"
|
|
testHTMLFile := os.TempDir() + "/" + testHTMLName
|
|
err := os.WriteFile(testHTMLFile, []byte("some html"), 0o700)
|
|
assert.NoError(t, err)
|
|
|
|
body, code := get(t, ts.URL+"/web/"+testHTMLName)
|
|
assert.Equal(t, http.StatusOK, code)
|
|
assert.Equal(t, "some html", body)
|
|
_ = os.Remove(testHTMLFile)
|
|
}
|
|
|
|
// TestRest_FileServerStaticAssets covers the static file server behaviors that are
|
|
// sensitive to the router: the bare /web -> /web/ redirect, cache headers applied to
|
|
// served assets, 404 for missing files, and the directory-listing block.
|
|
func TestRest_FileServerStaticAssets(t *testing.T) {
|
|
ts, srv, teardown := startupT(t)
|
|
defer teardown()
|
|
|
|
require.NoError(t, os.WriteFile(srv.WebRoot+"/asset-test.html", []byte("static body"), 0o600))
|
|
require.NoError(t, os.MkdirAll(srv.WebRoot+"/subdir-test", 0o700))
|
|
defer func() {
|
|
_ = os.Remove(srv.WebRoot + "/asset-test.html")
|
|
_ = os.RemoveAll(srv.WebRoot + "/subdir-test")
|
|
}()
|
|
|
|
noRedirect := http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
|
defer noRedirect.CloseIdleConnections()
|
|
|
|
t.Run("bare /web redirects to /web/", func(t *testing.T) {
|
|
resp, err := noRedirect.Get(ts.URL + "/web")
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusMovedPermanently, resp.StatusCode)
|
|
assert.Equal(t, "/web/", resp.Header.Get("Location"))
|
|
})
|
|
|
|
t.Run("serves an existing asset with cache headers", func(t *testing.T) {
|
|
resp, err := noRedirect.Get(ts.URL + "/web/asset-test.html")
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
assert.Equal(t, "static body", string(body))
|
|
assert.NotEmpty(t, resp.Header.Get("Etag"), "cacheControl must set an Etag on served assets")
|
|
assert.Contains(t, resp.Header.Get("Cache-Control"), "max-age", "cacheControl must set max-age on served assets")
|
|
})
|
|
|
|
t.Run("missing asset returns 404", func(t *testing.T) {
|
|
_, code := get(t, ts.URL+"/web/does-not-exist.html")
|
|
assert.Equal(t, http.StatusNotFound, code)
|
|
})
|
|
|
|
t.Run("directory listing is blocked", func(t *testing.T) {
|
|
resp, err := noRedirect.Get(ts.URL + "/web/subdir-test/")
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode, "directory listings must be blocked")
|
|
})
|
|
}
|
|
|
|
// TestRest_FileServerBackendAssets covers the assets embedded in the binary and the rule that a
|
|
// name the frontend build provides is served from there instead. WebRoot is a fresh empty
|
|
// directory so the frontend side is known, rather than the shared temp dir startupT defaults to.
|
|
func TestRest_FileServerBackendAssets(t *testing.T) {
|
|
ts, srv, teardown := startupT(t, func(srv *Rest) { srv.WebRoot = t.TempDir() })
|
|
defer teardown()
|
|
|
|
t.Run("serves every embedded asset byte for byte", func(t *testing.T) {
|
|
for _, name := range []string{"privacy.html", "markdown-help.html", "400x400.jpeg"} {
|
|
t.Run(name, func(t *testing.T) {
|
|
want, err := fs.ReadFile(webassets.FS, name)
|
|
require.NoError(t, err)
|
|
|
|
body, code := get(t, ts.URL+"/web/"+name)
|
|
assert.Equal(t, http.StatusOK, code)
|
|
assert.Equal(t, string(want), body, "the bytes must come from the embedded assets")
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("serves the image with its own content type", func(t *testing.T) {
|
|
resp, err := http.Get(ts.URL + "/web/400x400.jpeg")
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
assert.Equal(t, "image/jpeg", resp.Header.Get("Content-Type"))
|
|
})
|
|
|
|
t.Run("head is served", func(t *testing.T) {
|
|
resp, err := http.Head(ts.URL + "/web/privacy.html")
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
})
|
|
|
|
t.Run("frontend output wins over the embedded copy", func(t *testing.T) {
|
|
require.NoError(t, os.WriteFile(srv.WebRoot+"/privacy.html", []byte("operator's own policy"), 0o600))
|
|
t.Cleanup(func() { _ = os.Remove(srv.WebRoot + "/privacy.html") })
|
|
|
|
body, code := get(t, ts.URL+"/web/privacy.html")
|
|
assert.Equal(t, http.StatusOK, code)
|
|
assert.Equal(t, "operator's own policy", body)
|
|
})
|
|
|
|
t.Run("traversal out of the asset root is refused", func(t *testing.T) {
|
|
for _, p := range []string{"/web/../../etc/passwd", "/web/..%2f..%2fetc%2fpasswd", "/web/%2e%2e/%2e%2e/etc/passwd"} {
|
|
t.Run(p, func(t *testing.T) {
|
|
body, code := get(t, ts.URL+p)
|
|
assert.NotContains(t, body, "root:", "must never serve a file outside the served roots")
|
|
assert.NotEqual(t, http.StatusInternalServerError, code, "a rejected name must not surface as 500")
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("missing in both still returns 404", func(t *testing.T) {
|
|
_, code := get(t, ts.URL+"/web/neither-source-has-this.html")
|
|
assert.Equal(t, http.StatusNotFound, code)
|
|
})
|
|
}
|
|
|
|
// TestRest_FileServerEmbeddedFrontend covers the branch taken when no web root exists on disk,
|
|
// which is how the released binary runs. The frontend stands in for the copy embedded at
|
|
// app/cmd/web, so a name it provides and a name only the assets provide are both exercised.
|
|
func TestRest_FileServerEmbeddedFrontend(t *testing.T) {
|
|
frontend := fstest.MapFS{"index.html": {Data: []byte("embedded frontend index")}}
|
|
router := routegroup.New(http.NewServeMux())
|
|
addFileServer(router, frontend, filepath.Join(t.TempDir(), "absent"), "test-version")
|
|
|
|
ts := httptest.NewServer(router)
|
|
defer ts.Close()
|
|
|
|
t.Run("serves the embedded frontend", func(t *testing.T) {
|
|
body, code := get(t, ts.URL+"/web/index.html")
|
|
assert.Equal(t, http.StatusOK, code)
|
|
assert.Equal(t, "embedded frontend index", body)
|
|
})
|
|
|
|
for _, name := range []string{"privacy.html", "markdown-help.html", "400x400.jpeg"} {
|
|
t.Run("falls back to "+name, func(t *testing.T) {
|
|
want, err := fs.ReadFile(webassets.FS, name)
|
|
require.NoError(t, err)
|
|
|
|
body, code := get(t, ts.URL+"/web/"+name)
|
|
assert.Equal(t, http.StatusOK, code)
|
|
assert.Equal(t, string(want), body)
|
|
})
|
|
}
|
|
|
|
t.Run("a name neither source has is missing", func(t *testing.T) {
|
|
_, code := get(t, ts.URL+"/web/nothing-here.html")
|
|
assert.Equal(t, http.StatusNotFound, code)
|
|
})
|
|
|
|
t.Run("a name the operating system rejects is missing, not an error", func(t *testing.T) {
|
|
_, code := get(t, ts.URL+"/web/a%00b.html")
|
|
assert.Equal(t, http.StatusNotFound, code)
|
|
})
|
|
}
|
|
|
|
// TestRest_FileServerRoutesEmbedded drives the whole router the released binary runs: no web root
|
|
// on disk, and the frontend read from WebFS. It is what pins the web/ prefix routes() strips, which
|
|
// a test calling addFileServer directly cannot see.
|
|
func TestRest_FileServerRoutesEmbedded(t *testing.T) {
|
|
frontend := fstest.MapFS{
|
|
"web/index.html": {Data: []byte("embedded index")},
|
|
"web/iframe.html": {Data: []byte("embedded iframe")},
|
|
"web/remark.mjs": {Data: []byte("embedded bundle")},
|
|
}
|
|
ts, _, teardown := startupT(t, func(srv *Rest) {
|
|
srv.WebRoot = filepath.Join(t.TempDir(), "absent")
|
|
srv.WebFS = frontend
|
|
})
|
|
defer teardown()
|
|
|
|
t.Run("serves the frontend from under the web prefix", func(t *testing.T) {
|
|
for name, want := range map[string]string{
|
|
"index.html": "embedded index",
|
|
"iframe.html": "embedded iframe",
|
|
"remark.mjs": "embedded bundle",
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
body, code := get(t, ts.URL+"/web/"+name)
|
|
assert.Equal(t, http.StatusOK, code)
|
|
assert.Equal(t, want, body)
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("the prefix is stripped rather than exposed", func(t *testing.T) {
|
|
_, code := get(t, ts.URL+"/web/web/index.html")
|
|
assert.Equal(t, http.StatusNotFound, code, "the web/ prefix must not be reachable as a path")
|
|
})
|
|
|
|
t.Run("the embedded assets still answer alongside it", func(t *testing.T) {
|
|
want, err := fs.ReadFile(webassets.FS, "privacy.html")
|
|
require.NoError(t, err)
|
|
|
|
body, code := get(t, ts.URL+"/web/privacy.html")
|
|
assert.Equal(t, http.StatusOK, code)
|
|
assert.Equal(t, string(want), body)
|
|
})
|
|
}
|
|
|
|
// refusingSubFS is an fs.FS whose Sub refuses, which is the only way fs.Sub returns a nil
|
|
// filesystem. routes() has to survive it, since a nil frontend would panic on the first request.
|
|
type refusingSubFS struct{}
|
|
|
|
func (refusingSubFS) Open(name string) (fs.File, error) {
|
|
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
|
|
}
|
|
func (refusingSubFS) Sub(string) (fs.FS, error) { return nil, errors.New("refused") }
|
|
|
|
// TestRest_FileServerFrontendSourceRefused covers the branch where the frontend source cannot be
|
|
// sub-rooted: /web must keep serving the embedded assets rather than panicking.
|
|
func TestRest_FileServerFrontendSourceRefused(t *testing.T) {
|
|
ts, _, teardown := startupT(t, func(srv *Rest) {
|
|
srv.WebRoot = filepath.Join(t.TempDir(), "absent")
|
|
srv.WebFS = refusingSubFS{}
|
|
})
|
|
defer teardown()
|
|
|
|
t.Run("the embedded assets still serve", func(t *testing.T) {
|
|
want, err := fs.ReadFile(webassets.FS, "privacy.html")
|
|
require.NoError(t, err)
|
|
|
|
body, code := get(t, ts.URL+"/web/privacy.html")
|
|
assert.Equal(t, http.StatusOK, code)
|
|
assert.Equal(t, string(want), body)
|
|
})
|
|
|
|
t.Run("a frontend name is missing rather than fatal", func(t *testing.T) {
|
|
_, code := get(t, ts.URL+"/web/iframe.html")
|
|
assert.Equal(t, http.StatusNotFound, code)
|
|
})
|
|
}
|
|
|
|
// TestRest_RejectHeadOnDestructiveGET verifies that HEAD is blocked on the state-mutating
|
|
// GET routes (which stdlib http.ServeMux would otherwise route to the GET handler) while
|
|
// still being served for safe, read-only routes.
|
|
func TestRest_RejectHeadOnDestructiveGET(t *testing.T) {
|
|
ts, _, teardown := startupT(t)
|
|
defer teardown()
|
|
|
|
client := http.Client{}
|
|
defer client.CloseIdleConnections()
|
|
|
|
t.Run("HEAD is rejected on a destructive GET route", func(t *testing.T) {
|
|
req, err := http.NewRequest(http.MethodHead, ts.URL+"/api/v1/admin/deleteme?site=remark42", http.NoBody)
|
|
require.NoError(t, err)
|
|
req.SetBasicAuth("admin", "password")
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode, "HEAD must not reach a state-mutating GET handler")
|
|
assert.Equal(t, "GET", resp.Header.Get("Allow"), "405 must carry an Allow header")
|
|
})
|
|
|
|
t.Run("HEAD is rejected on the email unsubscribe route", func(t *testing.T) {
|
|
// emailUnsubscribeCtrl deletes the user's email subscription on GET, so HEAD (which
|
|
// ServeMux would route to the GET handler) must be rejected before it runs
|
|
resp, err := client.Head(ts.URL + "/email/unsubscribe.html?site=remark42")
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode, "HEAD must not reach the email-unsubscribe handler")
|
|
assert.Equal(t, "GET, POST", resp.Header.Get("Allow"), "Allow must list every method the resource supports")
|
|
})
|
|
|
|
t.Run("HEAD still works on a safe read-only route", func(t *testing.T) {
|
|
resp, err := client.Head(ts.URL + "/api/v1/config?site=remark42")
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode, "HEAD must still be served for safe read-only routes")
|
|
})
|
|
|
|
t.Run("wrong method on a known route returns 405 with Allow", func(t *testing.T) {
|
|
// method-in-pattern is new under ServeMux; a wrong method on a known route must
|
|
// still yield 405 with the allowed methods advertised
|
|
resp, err := client.Post(ts.URL+"/api/v1/config?site=remark42", "application/json", http.NoBody)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
|
|
assert.Contains(t, resp.Header.Get("Allow"), "GET", "405 must advertise the allowed methods")
|
|
})
|
|
}
|
|
|
|
// TestRest_AvatarMounts verifies both avatar mounts (root /avatar/ and /api/v1/avatar/)
|
|
// still route to the avatar handler after the chi Mount -> ServeMux Handle rewiring,
|
|
// rather than falling through to a router 404.
|
|
func TestRest_AvatarMounts(t *testing.T) {
|
|
ts, _, teardown := startupT(t)
|
|
defer teardown()
|
|
|
|
for _, path := range []string{"/api/v1/avatar/nonexistent.image", "/avatar/nonexistent.image"} {
|
|
t.Run(path, func(t *testing.T) {
|
|
body, code := get(t, ts.URL+path)
|
|
// the avatar handler responds (403 "can't load avatar"), not a router 404
|
|
assert.Equal(t, http.StatusForbidden, code, "avatar mount must reach the avatar handler")
|
|
assert.Contains(t, body, "can't load avatar", "request must reach the avatar handler, not a routing 404")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRest_Shutdown(t *testing.T) {
|
|
srv := Rest{Authenticator: &auth.Service{}, ImageProxy: &proxy.Image{}}
|
|
done := make(chan bool)
|
|
|
|
// without waiting for channel close at the end goroutine will stay alive after test finish
|
|
// which would create data race with next test
|
|
go func() {
|
|
time.Sleep(200 * time.Millisecond)
|
|
srv.Shutdown()
|
|
close(done)
|
|
}()
|
|
|
|
st := time.Now()
|
|
srv.Run("127.0.0.1", 0)
|
|
assert.True(t, time.Since(st).Seconds() < 1, "should take about 100ms")
|
|
<-done
|
|
}
|
|
|
|
func TestRest_filterComments(t *testing.T) {
|
|
user := store.User{ID: "user1", Name: "user name 1"}
|
|
c1 := store.Comment{User: user, Text: "test test #1", Locator: store.Locator{SiteID: "radio-t",
|
|
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 5, 27, 1, 14, 10, 0, time.Local)}
|
|
c2 := store.Comment{User: user, Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "radio-t",
|
|
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 5, 27, 1, 14, 20, 0, time.Local)}
|
|
c3 := store.Comment{User: user, Text: "test test #3", ParentID: "p1", Locator: store.Locator{SiteID: "radio-t",
|
|
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 5, 27, 1, 14, 25, 0, time.Local)}
|
|
|
|
r := filterComments([]store.Comment{c1, c2, c3}, func(c store.Comment) bool {
|
|
return c.Text == "test test #1" || c.Text == "test test #3"
|
|
})
|
|
assert.Equal(t, 2, len(r), "one comment filtered")
|
|
}
|
|
|
|
func TestRest_RunStaticSSLMode(t *testing.T) {
|
|
sslPort := chooseRandomUnusedPort()
|
|
srv := Rest{
|
|
Authenticator: auth.NewService(auth.Opts{
|
|
AvatarStore: avatar.NewLocalFS("/tmp"),
|
|
AvatarResizeLimit: 300,
|
|
}),
|
|
|
|
ImageProxy: &proxy.Image{},
|
|
SSLConfig: SSLConfig{
|
|
SSLMode: Static,
|
|
Port: sslPort,
|
|
Key: "../../cmd/testdata/key.pem",
|
|
Cert: "../../cmd/testdata/cert.pem",
|
|
},
|
|
RemarkURL: fmt.Sprintf("https://localhost:%d", sslPort),
|
|
}
|
|
|
|
port := chooseRandomUnusedPort()
|
|
go func() {
|
|
srv.Run("", port)
|
|
}()
|
|
|
|
waitForHTTPSServerStart(sslPort)
|
|
|
|
client := http.Client{
|
|
// prevent http redirect
|
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
|
|
// allow self-signed certificate
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
},
|
|
}
|
|
defer client.CloseIdleConnections()
|
|
|
|
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/blah?param=1", port))
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
|
assert.Equal(t, fmt.Sprintf("https://localhost:%d/blah?param=1", sslPort), resp.Header.Get("Location"))
|
|
|
|
resp, err = client.Get(fmt.Sprintf("https://localhost:%d/ping", sslPort))
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
body, err := io.ReadAll(resp.Body)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "pong", string(body))
|
|
|
|
srv.Shutdown()
|
|
}
|
|
|
|
func TestRest_RunAutocertModeHTTPOnly(t *testing.T) {
|
|
sslPort := chooseRandomUnusedPort()
|
|
srv := Rest{
|
|
Authenticator: &auth.Service{},
|
|
ImageProxy: &proxy.Image{},
|
|
SSLConfig: SSLConfig{
|
|
SSLMode: Auto,
|
|
Port: sslPort,
|
|
},
|
|
RemarkURL: fmt.Sprintf("https://localhost:%d", sslPort),
|
|
}
|
|
|
|
port := chooseRandomUnusedPort()
|
|
go func() {
|
|
// can't check https server locally, just only http server
|
|
srv.Run("", port)
|
|
}()
|
|
|
|
waitForHTTPSServerStart(sslPort)
|
|
|
|
client := http.Client{
|
|
// prevent http redirect
|
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
defer client.CloseIdleConnections()
|
|
|
|
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/blah?param=1", port))
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
|
assert.Equal(t, fmt.Sprintf("https://localhost:%d/blah?param=1", sslPort), resp.Header.Get("Location"))
|
|
|
|
srv.Shutdown()
|
|
}
|
|
|
|
func Test_URLKey(t *testing.T) {
|
|
tbl := []struct {
|
|
url string
|
|
user store.User
|
|
key string
|
|
}{
|
|
{"http://example.com/1", store.User{}, "http://example.com/1"},
|
|
{"http://example.com/1", store.User{ID: "user"}, "http://example.com/1"},
|
|
{"http://example.com/1", store.User{ID: "user", Admin: true}, "admin!!http://example.com/1"},
|
|
}
|
|
|
|
for i, tt := range tbl {
|
|
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
|
r, err := http.NewRequest("GET", tt.url, http.NoBody)
|
|
require.NoError(t, err)
|
|
if tt.user.ID != "" {
|
|
r = rest.SetUserInfo(r, tt.user)
|
|
}
|
|
assert.Equal(t, tt.key, URLKey(r))
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_URLKeyWithUser(t *testing.T) {
|
|
tbl := []struct {
|
|
url string
|
|
user store.User
|
|
key string
|
|
}{
|
|
{"http://example.com/1", store.User{}, "http://example.com/1"},
|
|
{"http://example.com/1", store.User{ID: "user"}, "user!!http://example.com/1"},
|
|
{"http://example.com/2", store.User{ID: "user2"}, "user2!!http://example.com/2"},
|
|
{"http://example.com/1", store.User{ID: "user", Admin: true}, "admin!!user!!http://example.com/1"},
|
|
}
|
|
|
|
for i, tt := range tbl {
|
|
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
|
r, err := http.NewRequest("GET", tt.url, http.NoBody)
|
|
require.NoError(t, err)
|
|
if tt.user.ID != "" {
|
|
r = rest.SetUserInfo(r, tt.user)
|
|
}
|
|
assert.Equal(t, tt.key, URLKeyWithUser(r))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRest_parseError(t *testing.T) {
|
|
tbl := []struct {
|
|
err error
|
|
res int
|
|
}{
|
|
{fmt.Errorf("can not vote for his own comment"), rest.ErrVoteSelf},
|
|
{fmt.Errorf("already voted for"), rest.ErrVoteDbl},
|
|
{fmt.Errorf("maximum number of votes exceeded for comment"), rest.ErrVoteMax},
|
|
{fmt.Errorf("minimal score reached for comment"), rest.ErrVoteMinScore},
|
|
{fmt.Errorf("too late to edit"), rest.ErrCommentEditExpired},
|
|
{fmt.Errorf("parent comment with reply can't be edited"), rest.ErrCommentEditChanged},
|
|
{fmt.Errorf("blah blah"), rest.ErrInternal},
|
|
}
|
|
|
|
for n, tt := range tbl {
|
|
t.Run(strconv.Itoa(n), func(t *testing.T) {
|
|
res := parseError(tt.err, rest.ErrInternal)
|
|
assert.Equal(t, tt.res, res)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRest_frameAncestors(t *testing.T) {
|
|
ts, _, teardown := startupT(t, func(o *Rest) {
|
|
o.AllowedAncestors = []string{"'self'", "https://example.com"}
|
|
})
|
|
|
|
// test case with frame-ancestors
|
|
client := http.Client{}
|
|
resp, err := client.Get(ts.URL + "/web/index.html")
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "frame-ancestors 'self' https://example.com;")
|
|
teardown()
|
|
|
|
// test case without frame-ancestors
|
|
ts, _, teardown = startupT(t, func(srv *Rest) {
|
|
srv.AllowedAncestors = []string{}
|
|
})
|
|
defer teardown()
|
|
resp, err = client.Get(ts.URL + "/web/index.html")
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "frame-ancestors *;")
|
|
}
|
|
|
|
// randomPath pick a file or folder name which is not in use for sure
|
|
func randomPath(tempDir, basename, suffix string) (string, error) {
|
|
for range 10 {
|
|
fname := fmt.Sprintf("/%s/%s-%d%s", tempDir, basename, rand.Int31(), suffix)
|
|
fmt.Printf("fname %q", fname)
|
|
_, err := os.Stat(fname)
|
|
if err != nil {
|
|
return fname, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("cannot create temp file in %s", tempDir)
|
|
}
|
|
|
|
// startupT runs fully configured testing server
|
|
// srvHook is an optional func to set some Rest param after the creation but prior to Run
|
|
func startupT(t *testing.T, srvHook ...func(srv *Rest)) (ts *httptest.Server, srv *Rest, teardown func()) {
|
|
tmp := os.TempDir()
|
|
testDB, err := randomPath(tmp, "test-remark", ".db")
|
|
require.NoError(t, err)
|
|
|
|
_ = os.RemoveAll(tmp + "/ava-remark42")
|
|
_ = os.RemoveAll(tmp + "/pics-remark42")
|
|
|
|
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: testDB, SiteID: "remark42"})
|
|
require.NoError(t, err)
|
|
|
|
memCache := cache.NewScache[[]byte](cache.NewNopCache[[]byte]())
|
|
|
|
astore := adminstore.NewStaticStore("123456", []string{"remark42"}, []string{"a1", "a2"}, "admin@remark-42.com")
|
|
restrictedWordsMatcher := service.NewRestrictedWordsMatcher(service.StaticRestrictedWordsLister{Words: []string{"duck"}})
|
|
|
|
dataStore := &service.DataStore{
|
|
Engine: b,
|
|
EditDuration: 5 * time.Minute,
|
|
MaxCommentSize: 4000,
|
|
AdminStore: astore,
|
|
MaxVotes: service.UnlimitedVotes,
|
|
RestrictedWordsMatcher: restrictedWordsMatcher,
|
|
}
|
|
|
|
remarkURL := "https://demo.remark42.com"
|
|
|
|
srv = &Rest{
|
|
DataService: dataStore,
|
|
Authenticator: auth.NewService(auth.Opts{
|
|
AdminPasswd: "password",
|
|
SecretReader: token.SecretFunc(func(string) (string, error) { return "secret", nil }),
|
|
AvatarStore: avatar.NewLocalFS(tmp + "/ava-remark42"),
|
|
}),
|
|
Cache: memCache,
|
|
WebRoot: tmp,
|
|
RemarkURL: remarkURL,
|
|
ImageService: image.NewService(&image.FileSystem{
|
|
Location: tmp + "/pics-remark42",
|
|
Partitions: 100,
|
|
Staging: tmp + "/pics-remark42/staging",
|
|
}, image.ServiceParams{
|
|
EditDuration: 100 * time.Millisecond,
|
|
ImageAPI: remarkURL + "/api/v1/picture/",
|
|
ProxyAPI: remarkURL + "/api/v1/img",
|
|
MaxSize: 10000,
|
|
}),
|
|
ImageProxy: &proxy.Image{},
|
|
ReadOnlyAge: 10,
|
|
CommentFormatter: store.NewCommentFormatter(&proxy.Image{}),
|
|
Migrator: &Migrator{
|
|
DisqusImporter: &migrator.Disqus{DataStore: dataStore},
|
|
WordPressImporter: &migrator.WordPress{DataStore: dataStore},
|
|
CommentoImporter: &migrator.Commento{DataStore: dataStore},
|
|
NativeImporter: &migrator.Native{DataStore: dataStore},
|
|
NativeExporter: &migrator.Native{DataStore: dataStore},
|
|
URLMapperMaker: migrator.NewURLMapper,
|
|
Cache: memCache,
|
|
KeyStore: astore,
|
|
},
|
|
NotifyService: notify.NopService,
|
|
EmojiEnabled: true,
|
|
openRouteLimiter: 100,
|
|
}
|
|
srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = -5, -10
|
|
|
|
// add some providers. Needed because we don't allow users with unlisted providers to authenticate
|
|
providers := []string{"provider1", "anonymous", "github", "email"}
|
|
for _, p := range providers {
|
|
srv.Authenticator.AddDirectProvider(p, provider.CredCheckerFunc(func(_, _ string) (ok bool, err error) {
|
|
return true, nil
|
|
}))
|
|
}
|
|
|
|
for _, h := range srvHook {
|
|
h(srv)
|
|
}
|
|
|
|
routes := srv.routes()
|
|
ts = httptest.NewServer(routes)
|
|
|
|
teardown = func() {
|
|
ts.Close()
|
|
require.NoError(t, srv.DataService.Close())
|
|
_ = os.Remove(testDB)
|
|
_ = os.RemoveAll(tmp + "/ava-remark42")
|
|
_ = os.RemoveAll(tmp + "/pics-remark42")
|
|
}
|
|
|
|
return ts, srv, teardown
|
|
}
|
|
|
|
// fake auth middleware make user authenticated and uses query's fake_id for ID and fake_name for Name
|
|
func fakeAuth(next http.Handler) http.Handler {
|
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Query().Get("fake_id") != "" {
|
|
r = rest.SetUserInfo(r, store.User{
|
|
ID: r.URL.Query().Get("fake_id"),
|
|
Name: r.URL.Query().Get("fake_name"),
|
|
})
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
}
|
|
return http.HandlerFunc(fn)
|
|
}
|
|
|
|
func get(t *testing.T, url string) (response string, statusCode int) {
|
|
r, err := http.Get(url)
|
|
require.NoError(t, err)
|
|
body, err := io.ReadAll(r.Body)
|
|
require.NoError(t, err)
|
|
require.NoError(t, r.Body.Close())
|
|
return string(body), r.StatusCode
|
|
}
|
|
|
|
func sendReq(_ *testing.T, r *http.Request, tkn string) (*http.Response, error) {
|
|
client := http.Client{Timeout: 5 * time.Second}
|
|
defer client.CloseIdleConnections()
|
|
if tkn != "" {
|
|
r.Header.Set("X-JWT", tkn)
|
|
}
|
|
return client.Do(r)
|
|
}
|
|
|
|
func getWithDevAuth(t *testing.T, url string) (body string, code int) {
|
|
client := &http.Client{Timeout: 5 * time.Second}
|
|
defer client.CloseIdleConnections()
|
|
req, err := http.NewRequest("GET", url, http.NoBody)
|
|
require.NoError(t, err)
|
|
req.Header.Add("X-JWT", devToken)
|
|
r, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
b, err := io.ReadAll(r.Body)
|
|
assert.NoError(t, err)
|
|
require.NoError(t, r.Body.Close())
|
|
return string(b), r.StatusCode
|
|
}
|
|
|
|
func getWithDev2Auth(t *testing.T, url string) (body string, code int) {
|
|
client := &http.Client{Timeout: 5 * time.Second}
|
|
defer client.CloseIdleConnections()
|
|
req, err := http.NewRequest("GET", url, http.NoBody)
|
|
require.NoError(t, err)
|
|
req.Header.Add("X-JWT", dev2Token)
|
|
r, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
b, err := io.ReadAll(r.Body)
|
|
assert.NoError(t, err)
|
|
require.NoError(t, r.Body.Close())
|
|
return string(b), r.StatusCode
|
|
}
|
|
|
|
func getWithAdminAuth(t *testing.T, url string) (response string, statusCode int) {
|
|
client := &http.Client{Timeout: 5 * time.Second}
|
|
defer client.CloseIdleConnections()
|
|
req, err := http.NewRequest("GET", url, http.NoBody)
|
|
require.NoError(t, err)
|
|
req.SetBasicAuth("admin", "password")
|
|
r, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
body, err := io.ReadAll(r.Body)
|
|
assert.NoError(t, err)
|
|
require.NoError(t, r.Body.Close())
|
|
return string(body), r.StatusCode
|
|
}
|
|
func post(t *testing.T, url, body string) (*http.Response, error) {
|
|
client := &http.Client{Timeout: 5 * time.Second}
|
|
defer client.CloseIdleConnections()
|
|
req, err := http.NewRequest("POST", url, strings.NewReader(body))
|
|
assert.NoError(t, err)
|
|
req.SetBasicAuth("admin", "password")
|
|
return client.Do(req)
|
|
}
|
|
|
|
func addCommentGetCreatedTime(t *testing.T, c store.Comment, ts *httptest.Server) (id string, created time.Time) {
|
|
b, err := json.Marshal(c)
|
|
require.NoError(t, err, "can't marshal comment %+v", c)
|
|
|
|
client := &http.Client{Timeout: 5 * time.Second}
|
|
defer client.CloseIdleConnections()
|
|
postURL := ts.URL + "/api/v1/comment"
|
|
if c.Locator.SiteID != "" {
|
|
postURL += "?site=" + c.Locator.SiteID
|
|
}
|
|
req, err := http.NewRequest("POST", postURL, bytes.NewBuffer(b))
|
|
require.NoError(t, err)
|
|
req.Header.Add("X-JWT", devToken)
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
b, err = io.ReadAll(resp.Body)
|
|
require.NoError(t, resp.Body.Close())
|
|
require.NoError(t, err)
|
|
|
|
crResp := R.JSON{}
|
|
err = json.Unmarshal(b, &crResp)
|
|
require.NoError(t, err)
|
|
time.Sleep(time.Nanosecond * 10)
|
|
created, err = time.Parse(time.RFC3339, crResp["time"].(string))
|
|
require.NoError(t, err)
|
|
return crResp["id"].(string), created
|
|
}
|
|
|
|
func addComment(t *testing.T, c store.Comment, ts *httptest.Server) string {
|
|
id, _ := addCommentGetCreatedTime(t, c, ts)
|
|
return id
|
|
}
|
|
|
|
func requireAdminOnly(t *testing.T, req *http.Request) {
|
|
resp, err := sendReq(t, req, "") // no-auth user
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
|
|
resp, err = sendReq(t, req, devToken) // non-admin user
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
|
}
|
|
|
|
func chooseRandomUnusedPort() (port int) {
|
|
for range 10 {
|
|
port = 40000 + int(rand.Int31n(10000))
|
|
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil {
|
|
_ = ln.Close()
|
|
break
|
|
}
|
|
}
|
|
return port
|
|
}
|
|
|
|
func waitForHTTPSServerStart(port int) {
|
|
// wait for up to 3 seconds for HTTPS server to start
|
|
for range 300 {
|
|
time.Sleep(time.Millisecond * 10)
|
|
conn, _ := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), time.Millisecond*10)
|
|
if conn != nil {
|
|
_ = conn.Close()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMain(m *testing.M) {
|
|
goleak.VerifyTestMain(
|
|
m,
|
|
// this will be fixed in https://github.com/hashicorp/golang-lru/issues/159
|
|
goleak.IgnoreTopFunction("github.com/hashicorp/golang-lru/v2/expirable.NewLRU[...].func1"),
|
|
)
|
|
}
|