package web
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gramanas/blogspace/internal/auth"
"github.com/gramanas/blogspace/internal/config"
"github.com/gramanas/blogspace/internal/store"
)
func TestFileType(t *testing.T) {
png := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("\x00", 16))
cases := []struct {
head string
name string
ct, kind string
}{
{string(png), "photo.png", "image/png", "image"},
{string(png), "evil.html", "image/png", "image"}, // bytes win over the name
{"\x00\x00\x01\x00\x01\x00", "icon.ico", "image/x-icon", "image"},
{"%PDF-1.4 ...", "paper.pdf", "application/pdf", "document"},
{"PK\x03\x04junk", "site.zip", "application/zip", "archive"},
{"\x1f\x8b\x08junk", "site.tar.gz", "application/x-gzip", "archive"},
{"ID3\x03\x00\x00\x00", "song.mp3", "audio/mpeg", "audio"},
{"OggS\x00\x02", "song.ogg", "audio/ogg", "audio"},
{"OggS\x00\x02", "clip.ogv", "video/ogg", "video"},
{"\x1a\x45\xdf\xa3junk", "clip.webm", "video/webm", "video"},
{"hello world", "notes.txt", "text/plain", "document"},
{"hello world", "server.log", "text/plain", "document"}, // text/* names only confirm the sniff
{"hello world", "notes.md", "text/plain", "document"},
{"hello world", "README", "text/plain", "document"}, // no extension: the bytes are all we have
{"\x00\x01\x02\x03\xff\xfe", "font.ttf", "application/octet-stream", "other"},
{"hello world", "data.csv", "text/csv", "document"},
{"hello world", "evil.html", "application/octet-stream", "other"},
{"", "evil.svg", "application/octet-stream", "other"},
{"", "pic.svg", "application/octet-stream", "other"},
{"", "page.html", "application/octet-stream", "other"},
{"alert(1)", "x.js", "application/octet-stream", "other"},
{"\x00\x01\x02\x03\xff\xfe", "song.mp3", "audio/mpeg", "audio"},
{"\x00\x01\x02\x03\xff\xfe", "tool.exe", "application/octet-stream", "other"},
{"\x00\x01\x02\x03\xff\xfe", "book.epub", "application/epub+zip", "document"},
{"\x00\x01\x02\x03\xff\xfe", "noext", "application/octet-stream", "other"},
}
for _, c := range cases {
ct, kind := fileType([]byte(c.head), c.name)
if ct != c.ct || kind != c.kind {
t.Errorf("fileType(%q, %q) = %s, %s; want %s, %s", c.head[:min(8, len(c.head))], c.name, ct, kind, c.ct, c.kind)
}
}
}
func TestServedAs(t *testing.T) {
cases := []struct {
ct string
download bool
ctype string
disp string
}{
{"image/png", false, "image/png", "inline"},
{"image/png", true, "image/png", "attachment"},
{"application/pdf", false, "application/pdf", "inline"},
{"text/plain", false, "text/plain; charset=utf-8", "inline"},
{"audio/mpeg", false, "audio/mpeg", "inline"},
{"video/mp4", false, "video/mp4", "inline"},
{"application/zip", false, "application/octet-stream", "attachment"},
{"text/html", false, "application/octet-stream", "attachment"},
{"image/svg+xml", false, "application/octet-stream", "attachment"},
{"application/javascript", true, "application/octet-stream", "attachment"},
}
for _, c := range cases {
ctype, disp := servedAs(c.ct, c.download)
if ctype != c.ctype || disp != c.disp {
t.Errorf("servedAs(%s, %v) = %s, %s; want %s, %s", c.ct, c.download, ctype, disp, c.ctype, c.disp)
}
}
}
func TestContentDisposition(t *testing.T) {
if got := contentDisposition("inline", "a.pdf"); got != `inline; filename="a.pdf"; filename*=utf-8''a.pdf` {
t.Errorf("ascii: %s", got)
}
got := contentDisposition("attachment", `έγγρα"φο.pdf`)
if !strings.HasPrefix(got, `attachment; filename="________.pdf"; filename*=utf-8''%CE%AD`) {
t.Errorf("greek: %s", got)
}
}
func TestCleanFilename(t *testing.T) {
cases := map[string]string{
`C:\Users\me\photo.png`: "photo.png",
"../../etc/passwd": "passwd",
" spaced .txt ": "spaced .txt",
"": "file",
".": "file",
"/": "file",
"a\"b'c\x00d.txt": "abcd.txt",
"φωτογραφία.jpg": "φωτογραφία.jpg",
}
for in, want := range cases {
if got := cleanFilename(in); got != want {
t.Errorf("cleanFilename(%q) = %q, want %q", in, got, want)
}
}
if got := cleanFilename(strings.Repeat("α", 200)); len([]rune(got)) != 120 {
t.Errorf("long name not capped: %d runes", len([]rune(got)))
}
}
func TestHumanSize(t *testing.T) {
cases := map[int64]string{0: "1 KB", 100: "1 KB", 512 << 10: "512 KB", 1 << 20: "1 MB", 1536 << 10: "1.5 MB", 10 << 20: "10 MB"}
for in, want := range cases {
if got := humanSize(in); got != want {
t.Errorf("humanSize(%d) = %q, want %q", in, got, want)
}
}
}
func TestPageBounds(t *testing.T) {
cases := []struct{ total, per, n, offset, page, last int }{
{0, 50, 1, 0, 1, 1}, {0, 50, 7, 0, 1, 1}, {50, 50, 2, 0, 1, 1},
{120, 50, 3, 100, 3, 3}, {120, 50, 9, 100, 3, 3}, {120, 50, 0, 0, 1, 3}, {120, 50, 2, 50, 2, 3},
}
for _, c := range cases {
o, p, l := pageBounds(c.total, c.per, c.n)
if o != c.offset || p != c.page || l != c.last {
t.Errorf("pageBounds(%d,%d,%d) = %d,%d,%d; want %d,%d,%d", c.total, c.per, c.n, o, p, l, c.offset, c.page, c.last)
}
}
}
func TestFileBadge(t *testing.T) {
for name, want := range map[string]string{"a.pdf": "PDF", "site.tar.gz": "GZ", "README": "FILE", "x.verylongext": "FILE", "α.ΈΓΓΡΑΦΟ": "FILE", "n.txt": "TXT"} {
if got := (store.File{Filename: name}).Badge(); got != want {
t.Errorf("Badge(%q) = %q, want %q", name, got, want)
}
}
}
// guardPOST runs before any store access, so it can be exercised with a nil store.
func TestGuardPOST(t *testing.T) {
secret := []byte("test-secret")
s := NewServer(&config.Config{BaseDomain: "example.com", JWTSecret: secret, MaxUploadBytes: 1 << 20}, nil)
u := &store.User{ID: 1}
post := func(body string, accept string) (*httptest.ResponseRecorder, *http.Request) {
r := httptest.NewRequest("POST", "/b/alice/files/upload", strings.NewReader(body))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if accept != "" {
r.Header.Set("Accept", accept)
}
r = withLang(r.WithContext(context.WithValue(r.Context(), ctxUser, u)), "en")
return httptest.NewRecorder(), r
}
token := auth.CSRFToken(secret, u.ID, u.TokenVersion)
// over the cap (limit + 1 MB overhead)
w, r := post("x="+strings.Repeat("a", 2<<20+10), "")
if s.guardPOST(w, r, 1<<20) || w.Code != http.StatusRequestEntityTooLarge || !strings.Contains(w.Body.String(), "1 MB") {
t.Errorf("too big: code %d body %q", w.Code, w.Body.String())
}
w, r = post("x="+strings.Repeat("a", 2<<20+10), "application/json")
var j map[string]string
if s.guardPOST(w, r, 1<<20) || w.Code != http.StatusRequestEntityTooLarge || json.NewDecoder(w.Body).Decode(&j) != nil || j["error"] == "" {
t.Errorf("too big (json): code %d", w.Code)
}
// a bigger limit lets the same body through (CSRF aside)
w, r = post("x="+strings.Repeat("a", 2<<20+10)+"&_csrf="+url.QueryEscape(token), "")
if !s.guardPOST(w, r, 4<<20) {
t.Errorf("under a 4 MB limit: code %d", w.Code)
}
// missing / valid token
w, r = post("x=1", "")
if s.guardPOST(w, r, 1<<20) || w.Code != http.StatusForbidden {
t.Errorf("missing csrf: code %d", w.Code)
}
w, r = post("x=1&_csrf="+url.QueryEscape(token), "")
if !s.guardPOST(w, r, 1<<20) || r.FormValue("x") != "1" {
t.Errorf("valid token: code %d", w.Code)
}
// GET is never touched
r = httptest.NewRequest("GET", "/b/alice/files", nil)
if !s.guardPOST(httptest.NewRecorder(), r, 0) {
t.Error("GET should pass")
}
_ = bytes.MinRead
}