Add files page
This commit is contained in:
+143
-40
@@ -799,7 +799,7 @@ func TestFailedLoginsAreThrottled(t *testing.T) {
|
||||
func TestLoginRedirectIsAllowlisted(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
for _, c := range []struct{ next, want string }{
|
||||
{"admin", "/admin"},
|
||||
{"files", "/files"},
|
||||
{"", "/"},
|
||||
{"https://evil.example.com", "/"},
|
||||
{"//evil.example.com", "/"},
|
||||
@@ -1198,33 +1198,135 @@ func (h *harness) get(t *testing.T, path, token string) *http.Response {
|
||||
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) {
|
||||
// The listing is open to anyone with a token; what changes is its contents.
|
||||
// Only a credential that does not resolve is turned away.
|
||||
func TestFilesPageAccess(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
cases := []struct {
|
||||
for _, c := range []struct {
|
||||
who string
|
||||
token string
|
||||
want int
|
||||
}{
|
||||
{"anonymous", "", http.StatusUnauthorized},
|
||||
{"anonymous", "", http.StatusOK},
|
||||
{"an unknown token", "not-a-token", http.StatusUnauthorized},
|
||||
{"a non-admin token", h.token, http.StatusForbidden},
|
||||
{"a plain token", h.token, http.StatusOK},
|
||||
{"an admin token", h.admin, http.StatusOK},
|
||||
}
|
||||
for _, c := range cases {
|
||||
resp := h.get(t, "/admin", c.token)
|
||||
} {
|
||||
resp := h.get(t, "/files", 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)
|
||||
t.Errorf("GET /files as %s => %s, want %d", c.who, resp.Status, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The cookie is the credential a browser actually uses for this page.
|
||||
func TestAdminPageAcceptsTheSession(t *testing.T) {
|
||||
// The whole point of the page for a token holder: their own uploads come back,
|
||||
// and nobody else's do. Without this there is no way to find a file again once
|
||||
// the link has been lost.
|
||||
func TestFilesPageListsOnlyYourOwnUploads(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+"/admin", nil)
|
||||
|
||||
h.upload(t, []byte("mine"), map[string]string{
|
||||
"Authorization": "Bearer " + h.token,
|
||||
"Content-Disposition": `attachment; filename="mine.bin"`}).Body.Close()
|
||||
h.upload(t, []byte("theirs"), map[string]string{
|
||||
"Authorization": "Bearer " + h.admin,
|
||||
"Content-Disposition": `attachment; filename="theirs.bin"`}).Body.Close()
|
||||
h.upload(t, []byte("nobodys"), map[string]string{
|
||||
"Content-Disposition": `attachment; filename="nobodys.bin"`}).Body.Close()
|
||||
|
||||
resp := h.get(t, "/files", h.token)
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
page := string(raw)
|
||||
|
||||
if !strings.Contains(page, "mine.bin") {
|
||||
t.Error("a token holder cannot see their own upload")
|
||||
}
|
||||
for _, hidden := range []string{"theirs.bin", "nobodys.bin"} {
|
||||
if strings.Contains(page, hidden) {
|
||||
t.Errorf("the listing shows %q, which belongs to someone else", hidden)
|
||||
}
|
||||
}
|
||||
// The server's own state is an admin's business, not a guest's.
|
||||
for _, secret := range []string{"Free disk", "Tokens", "boss"} {
|
||||
if strings.Contains(page, secret) {
|
||||
t.Errorf("a plain token's listing exposes %q", secret)
|
||||
}
|
||||
}
|
||||
// An owner column would be a column of one repeated name.
|
||||
if strings.Contains(page, `data-label="Owner"`) {
|
||||
t.Error("the listing carries an owner column for a reader who owns everything in it")
|
||||
}
|
||||
|
||||
// The admin, by contrast, sees all three.
|
||||
resp = h.get(t, "/files", h.admin)
|
||||
raw, _ = io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
for _, want := range []string{"mine.bin", "theirs.bin", "nobodys.bin"} {
|
||||
if !strings.Contains(string(raw), want) {
|
||||
t.Errorf("the admin listing is missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous uploads are recorded against nobody, so the page says so rather
|
||||
// than pretending to be empty or refusing outright.
|
||||
func TestFilesPageAsksAnonymousVisitorsToLogIn(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
h.upload(t, []byte("nobodys"), map[string]string{
|
||||
"Content-Disposition": `attachment; filename="nobodys.bin"`}).Body.Close()
|
||||
|
||||
resp := h.get(t, "/files", "")
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
page := string(raw)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %s, want 200: the page exists, it just needs a token", resp.Status)
|
||||
}
|
||||
if !strings.Contains(page, `href="/login?next=files`) {
|
||||
t.Error("the page does not offer a way to log in and come back")
|
||||
}
|
||||
if strings.Contains(page, "nobodys.bin") {
|
||||
t.Error("an anonymous visitor is shown files they cannot be known to own")
|
||||
}
|
||||
}
|
||||
|
||||
// A token holder may delete what they uploaded, and lands back on the listing.
|
||||
func TestOwnerDeletesFromTheListing(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
res := decode[uploadResult](t, h.upload(t, []byte("mine"), map[string]string{
|
||||
"Authorization": "Bearer " + h.token,
|
||||
"Content-Disposition": `attachment; filename="mine.bin"`}))
|
||||
|
||||
client := *h.ts.Client()
|
||||
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", strings.NewReader("from=files"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "text/html")
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token})
|
||||
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 != "/files" {
|
||||
t.Errorf("Location = %q, want /files", loc)
|
||||
}
|
||||
if _, err := h.store.Get(res.ID, h.now); err == nil {
|
||||
t.Error("the owner's own file was not deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// The cookie is the credential a browser actually uses for this page.
|
||||
func TestFilesPageAcceptsTheSession(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+"/files", nil)
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.admin})
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
@@ -1232,11 +1334,11 @@ func TestAdminPageAcceptsTheSession(t *testing.T) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET /admin with an admin cookie => %s", resp.Status)
|
||||
t.Fatalf("GET /files with an admin cookie => %s", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPageListsEveryoneAndHidesExpired(t *testing.T) {
|
||||
func TestAdminSeesEveryFileAndNoExpiredOnes(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
|
||||
// One anonymous, one owned, one that will have expired by the time the
|
||||
@@ -1252,18 +1354,18 @@ func TestAdminPageListsEveryoneAndHidesExpired(t *testing.T) {
|
||||
"Content-Disposition": `attachment; filename="expired.bin"`}).Body.Close()
|
||||
|
||||
h.now = clock.Add(2 * time.Hour)
|
||||
resp := h.get(t, "/admin", h.admin)
|
||||
resp := h.get(t, "/files", 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)
|
||||
t.Errorf("the admin listing 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")
|
||||
t.Error("the admin listing shows an expired file as though it were still stored")
|
||||
}
|
||||
// Token names and limits are shown; nothing secret is.
|
||||
if !strings.Contains(page, "boss") {
|
||||
@@ -1271,7 +1373,7 @@ func TestAdminPageListsEveryoneAndHidesExpired(t *testing.T) {
|
||||
}
|
||||
for _, secret := range []string{h.admin, h.token} {
|
||||
if strings.Contains(page, secret) {
|
||||
t.Error("the admin page echoes a token secret")
|
||||
t.Error("the listing echoes a token secret")
|
||||
}
|
||||
if strings.Contains(page, auth.HashSecret(secret)) {
|
||||
t.Error("the admin page exposes a token hash")
|
||||
@@ -1279,12 +1381,12 @@ func TestAdminPageListsEveryoneAndHidesExpired(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSortIsRestrictedToKnownColumns(t *testing.T) {
|
||||
func TestFilesSortIsRestrictedToKnownColumns(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 := h.get(t, "/files?sort="+url.QueryEscape(sort), h.admin)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("sort=%q => %s", sort, resp.Status)
|
||||
@@ -1293,14 +1395,14 @@ func TestAdminSortIsRestrictedToKnownColumns(t *testing.T) {
|
||||
}
|
||||
|
||||
// Deleting from the table returns to the table rather than to a dead end.
|
||||
func TestAdminDeleteReturnsToTheTable(t *testing.T) {
|
||||
func TestAdminDeleteReturnsToTheListing(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")
|
||||
form := strings.NewReader("from=files")
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "text/html")
|
||||
@@ -1314,8 +1416,8 @@ func TestAdminDeleteReturnsToTheTable(t *testing.T) {
|
||||
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 loc := resp.Header.Get("Location"); loc != "/files" {
|
||||
t.Errorf("Location = %q, want /files", loc)
|
||||
}
|
||||
if _, err := h.store.Get(res.ID, h.now); err == nil {
|
||||
t.Error("the file was not deleted")
|
||||
@@ -1327,7 +1429,7 @@ 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")
|
||||
form := strings.NewReader("from=files")
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
@@ -1342,9 +1444,10 @@ func TestAdminDeleteStillRequiresAdmin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// The header link is the only way to discover the page, so it has to appear
|
||||
// for everyone who has a listing to see - which is anyone logged in, not just
|
||||
// an admin - and for nobody who does not.
|
||||
func TestFilesLinkIsShownToEveryoneLoggedIn(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
for _, c := range []struct {
|
||||
who string
|
||||
@@ -1352,7 +1455,7 @@ func TestAdminLinkIsShownOnlyToAdmins(t *testing.T) {
|
||||
want bool
|
||||
}{
|
||||
{"anonymous", "", false},
|
||||
{"a non-admin token", h.token, false},
|
||||
{"a plain token", h.token, true},
|
||||
{"an admin token", h.admin, true},
|
||||
} {
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
|
||||
@@ -1365,8 +1468,8 @@ func TestAdminLinkIsShownOnlyToAdmins(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
if got := strings.Contains(string(raw), `href="/files"`); got != c.want {
|
||||
t.Errorf("files link shown to %s = %v, want %v", c.who, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1677,9 +1780,9 @@ func TestHeaderReflectsTheSession(t *testing.T) {
|
||||
present []string
|
||||
absent []string
|
||||
}{
|
||||
{"anonymous", "", []string{`href="/login"`}, []string{`action="/logout"`, `href="/admin"`}},
|
||||
{"a plain token", h.token, []string{`action="/logout"`, ">friend<"}, []string{`href="/login"`, `href="/admin"`}},
|
||||
{"an admin token", h.admin, []string{`action="/logout"`, `href="/admin"`, ">boss<"}, []string{`href="/login"`}},
|
||||
{"anonymous", "", []string{`href="/login"`}, []string{`action="/logout"`, `href="/files"`}},
|
||||
{"a plain token", h.token, []string{`action="/logout"`, ">friend<", `href="/files"`}, []string{`href="/login"`}},
|
||||
{"an admin token", h.admin, []string{`action="/logout"`, `href="/files"`, ">boss<"}, []string{`href="/login"`}},
|
||||
} {
|
||||
for _, path := range []string{"/", "/login"} {
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+path, nil)
|
||||
@@ -1983,7 +2086,7 @@ func TestRotationEndsLiveSessions(t *testing.T) {
|
||||
|
||||
// Sorting has to compare the underlying values, not their rendered form: two
|
||||
// uploads in the same minute render identically but are not equal.
|
||||
func TestAdminSortOrdersByValue(t *testing.T) {
|
||||
func TestFilesSortOrdersByValue(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
|
||||
// Three files, distinct in every sortable dimension.
|
||||
@@ -2009,7 +2112,7 @@ func TestAdminSortOrdersByValue(t *testing.T) {
|
||||
h.now = clock
|
||||
|
||||
order := func(sortBy string) []string {
|
||||
resp := h.get(t, "/admin?sort="+sortBy, h.admin)
|
||||
resp := h.get(t, "/files?sort="+sortBy, h.admin)
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
var ids []string
|
||||
@@ -2037,11 +2140,11 @@ func TestAdminSortOrdersByValue(t *testing.T) {
|
||||
|
||||
// The listing is a table, and a table needs more room than a form. It also has
|
||||
// to stop being a table on a narrow screen rather than grow a scrollbar.
|
||||
func TestAdminPageIsLaidOutForATable(t *testing.T) {
|
||||
func TestFilesPageIsLaidOutForATable(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
h.upload(t, []byte("x"), nil).Body.Close()
|
||||
|
||||
resp := h.get(t, "/admin", h.admin)
|
||||
resp := h.get(t, "/files", h.admin)
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
page := string(raw)
|
||||
|
||||
Reference in New Issue
Block a user