Add admin interface
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -1023,3 +1024,252 @@ func TestUploadJSONCarriesBothLinks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- administration ------------------------------------------------------
|
||||
|
||||
func (h *harness) get(t *testing.T, path, token string) *http.Response {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+path, nil)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// The admin page shows every stored file, so who may open it is the whole
|
||||
// security story for this feature.
|
||||
func TestAdminPageAccessControl(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
cases := []struct {
|
||||
who string
|
||||
token string
|
||||
want int
|
||||
}{
|
||||
{"anonymous", "", http.StatusUnauthorized},
|
||||
{"an unknown token", "not-a-token", http.StatusUnauthorized},
|
||||
{"a non-admin token", h.token, http.StatusForbidden},
|
||||
{"an admin token", h.admin, http.StatusOK},
|
||||
}
|
||||
for _, c := range cases {
|
||||
resp := h.get(t, "/admin", c.token)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != c.want {
|
||||
t.Errorf("GET /admin as %s => %s, want %d", c.who, resp.Status, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The cookie is the credential a browser actually uses for this page.
|
||||
func TestAdminPageAcceptsTheRememberedCookie(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+"/admin", nil)
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.admin})
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET /admin with an admin cookie => %s", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPageListsEveryoneAndHidesExpired(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
|
||||
// One anonymous, one owned, one that will have expired by the time the
|
||||
// page is rendered.
|
||||
h.upload(t, []byte("anon"), map[string]string{
|
||||
"Content-Disposition": `attachment; filename="anonymous.bin"`}).Body.Close()
|
||||
h.upload(t, []byte("owned"), map[string]string{
|
||||
"Authorization": "Bearer " + h.token,
|
||||
"Vanity": "friends-file",
|
||||
"Content-Disposition": `attachment; filename="owned.bin"`}).Body.Close()
|
||||
h.upload(t, []byte("gone"), map[string]string{
|
||||
"Expiry": "1h",
|
||||
"Content-Disposition": `attachment; filename="expired.bin"`}).Body.Close()
|
||||
|
||||
h.now = clock.Add(2 * time.Hour)
|
||||
resp := h.get(t, "/admin", h.admin)
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
page := string(raw)
|
||||
|
||||
for _, want := range []string{"anonymous.bin", "owned.bin", "friends-file", "friend"} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Errorf("the admin page does not list %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(page, "expired.bin") {
|
||||
t.Error("the admin page lists an expired file as though it were still stored")
|
||||
}
|
||||
// Token names and limits are shown; nothing secret is.
|
||||
if !strings.Contains(page, "boss") {
|
||||
t.Error("the token table does not list the tokens")
|
||||
}
|
||||
for _, secret := range []string{h.admin, h.token} {
|
||||
if strings.Contains(page, secret) {
|
||||
t.Error("the admin page echoes a token secret")
|
||||
}
|
||||
if strings.Contains(page, auth.HashSecret(secret)) {
|
||||
t.Error("the admin page exposes a token hash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSortIsRestrictedToKnownColumns(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
h.upload(t, []byte("x"), nil).Body.Close()
|
||||
|
||||
for _, sort := range []string{"size", "created", "expires", "name", "owner", "", "../../etc", "nonsense"} {
|
||||
resp := h.get(t, "/admin?sort="+url.QueryEscape(sort), h.admin)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("sort=%q => %s", sort, resp.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting from the table returns to the table rather than to a dead end.
|
||||
func TestAdminDeleteReturnsToTheTable(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
res := decode[uploadResult](t, h.upload(t, []byte("someone else's"), nil))
|
||||
|
||||
client := *h.ts.Client()
|
||||
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
|
||||
form := strings.NewReader("from=admin")
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/api/d/"+res.ID+"/delete", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "text/html")
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.admin})
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("status = %s, want 303", resp.Status)
|
||||
}
|
||||
if loc := resp.Header.Get("Location"); loc != "/admin" {
|
||||
t.Errorf("Location = %q, want /admin", loc)
|
||||
}
|
||||
if _, err := h.store.Get(res.ID, h.now); err == nil {
|
||||
t.Error("the file was not deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// A non-admin must not be able to delete someone else's file from that form.
|
||||
func TestAdminDeleteStillRequiresAdmin(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
res := decode[uploadResult](t, h.upload(t, []byte("not yours"), nil))
|
||||
|
||||
form := strings.NewReader("from=admin")
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/api/d/"+res.ID+"/delete", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token})
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("status = %s, want 403", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// The header link is the only way to discover the page, so it must appear for
|
||||
// an admin and never for anyone else.
|
||||
func TestAdminLinkIsShownOnlyToAdmins(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
for _, c := range []struct {
|
||||
who string
|
||||
token string
|
||||
want bool
|
||||
}{
|
||||
{"anonymous", "", false},
|
||||
{"a non-admin token", h.token, false},
|
||||
{"an admin token", h.admin, true},
|
||||
} {
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
|
||||
if c.token != "" {
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: c.token})
|
||||
}
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if got := strings.Contains(string(raw), `href="/admin"`); got != c.want {
|
||||
t.Errorf("admin link shown to %s = %v, want %v", c.who, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mixing the two request shapes — a multipart body with the headers the raw
|
||||
// shape uses — must not silently discard the options. Being handed a UUID when
|
||||
// you asked for a name is worse than being told no.
|
||||
func TestMultipartHonoursTheHeaderForm(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
fw, _ := mw.CreateFormFile("file", "build.zip")
|
||||
fw.Write([]byte("payload"))
|
||||
mw.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", &body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+h.token)
|
||||
req.Header.Set("Vanity", "friends-build")
|
||||
req.Header.Set("Expiry", "1h")
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %s", resp.Status)
|
||||
}
|
||||
res := decode[uploadResult](t, resp)
|
||||
if res.ID != "friends-build" {
|
||||
t.Errorf("id = %q, want friends-build: the Vanity header was ignored", res.ID)
|
||||
}
|
||||
if want := clock.Add(time.Hour).UTC().Format(time.RFC3339); res.Expires != want {
|
||||
t.Errorf("expires = %q, want %q: the Expiry header was ignored", res.Expires, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A form field still wins, so the browser's own controls stay authoritative.
|
||||
func TestFormFieldsOverrideTheHeaders(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
mw.WriteField("vanity", "from-the-form")
|
||||
mw.WriteField("expiry", "")
|
||||
fw, _ := mw.CreateFormFile("file", "build.zip")
|
||||
fw.Write([]byte("payload"))
|
||||
mw.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", &body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+h.token)
|
||||
req.Header.Set("Vanity", "from-the-header")
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := decode[uploadResult](t, resp)
|
||||
if res.ID != "from-the-form" {
|
||||
t.Errorf("id = %q, want the form field to win", res.ID)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user