1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
package main

// The routing table.
//
// Every URL in mailweb is matched here, which is the reason this file exists:
// the patterns carry the method and the path variables, so a mistake in one is
// not a compile error and does not show up until the route is requested. The
// cases below are the shapes that were hand-parsed before — a percent-encoded
// address, a Content-ID with an @ in it, a trailing slash — plus the methods
// each route refuses.
//
// The database is real but empty, so a route that resolves an id answers 404.
// That is the point: 404 means the request reached the handler and the handler
// looked, whereas 405 or a 404 from the mux means it never got there.

import (
	"net/http"
	"net/http/httptest"
	"path/filepath"
	"strings"
	"testing"
)

// newTestServer builds a server over an empty database in a temporary
// directory. No IMAP connection is made: nothing here reaches a handler that
// needs one, which is what routing tests are for.
func newTestServer(t *testing.T) *server {
	t.Helper()
	path := filepath.Join(t.TempDir(), "test.db")
	write, err := openDB(path)
	if err != nil {
		t.Fatalf("open db: %v", err)
	}
	t.Cleanup(func() { write.Close() })
	read, err := openReadPool(path, 2)
	if err != nil {
		t.Fatalf("open read pool: %v", err)
	}
	t.Cleanup(func() { read.Close() })
	return &server{db: &database{Read: read, Write: write}}
}

func TestRoutes(t *testing.T) {
	accountName = "test"
	myAddress = "me@example.org"
	myAddresses = map[string]bool{myAddress: true}

	mux := newTestServer(t).routes()

	// want is the status expected. 404 from a handler that looked something up
	// and found nothing is a pass: it proves the route matched and the path
	// value arrived.
	cases := []struct {
		method, path string
		want         int
		note         string
	}{
		{"GET", "/", 200, "index"},
		// The root pattern is {$}, so it must not swallow unrouted paths.
		{"GET", "/nonsense", 404, "unrouted path is not the index"},
		{"POST", "/", 405, "index is GET only"},

		{"GET", "/msg/1", 404, "no such message"},
		{"GET", "/msg/notanumber", 404, "non-numeric id"},
		// The page around the fragment. Both resolve the same id, so both 404
		// on an absent message; /view must not be swallowed by /msg/{id}.
		{"GET", "/msg/1/view", 404, "page for an absent message"},
		{"POST", "/msg/1/view", 405, "the message page is GET only"},
		{"GET", "/msg/1/part/cid%40example.com", 404, "cid is decoded, message absent"},
		{"GET", "/msg/1/attachment/1", 404, "attachment of absent message"},
		{"GET", "/msg/1/attachment/0", 400, "attachments count from one"},
		{"GET", "/msg/1/attachment/x", 400, "non-numeric index"},
		// The summary of a calendar attachment. Same lookup as the bytes, so
		// the same answers; it must not be swallowed by the route above.
		{"GET", "/msg/1/attachment/1/calendar", 404, "calendar of absent message"},
		{"GET", "/msg/1/attachment/x/calendar", 400, "non-numeric index"},
		{"POST", "/msg/1/attachment/1/calendar", 405, "the summary is GET only"},
		// The same part asked to be displayed rather than saved. Likewise a
		// sibling of the bytes and not a variant of them, so it resolves the
		// same way and must not be swallowed by /attachment/{idx} either.
		{"GET", "/msg/1/attachment/1/inline", 404, "inline view of absent message"},
		{"GET", "/msg/1/attachment/x/inline", 400, "non-numeric index"},
		{"POST", "/msg/1/attachment/1/inline", 405, "the inline view is GET only"},

		// The GET is registered only so the refusal can explain itself; a bare
		// 405 from the mux would not say where to go instead.
		{"GET", "/msg/1/reply", 405, "reply refuses GET"},
		{"POST", "/msg/1/reply", 404, "reply to absent message"},
		{"DELETE", "/msg/1/reply", 405, "reply refuses other methods"},

		{"GET", "/contacts", 200, "contacts listing"},
		{"GET", "/contacts?show=hidden", 200, "hidden contacts"},
		// An address that names no contact 404s rather than rendering a page
		// full of action links minted from a typed URL.
		{"GET", "/contact/nobody%40example.org", 404, "unknown contact"},
		{"POST", "/contact/nobody%40example.org", 405, "contact detail is GET only"},
		{"GET", "/contact/a%40b.com/hide", 405, "hide is POST only"},
		{"GET", "/contact/a%40b.com/petname", 405, "petname is POST only"},
		{"GET", "/contact/nobody%40example.org/report-spam", 200, "report form"},
		// The settings page mints the same action links the contact page does,
		// so it answers 404 by the same rule. The petname POST stays exempt.
		{"GET", "/contact/nobody%40example.org/settings", 404, "settings for an unknown contact"},
		{"POST", "/contact/nobody%40example.org/settings", 405, "settings is GET only"},

		{"GET", "/forge", 200, "forge listing"},
		{"GET", "/forge/", 302, "trailing slash redirects to the listing"},
		{"GET", "/forge/nosuch.repo", 404, "unknown repo"},

		{"GET", "/drafts", 200, "drafts listing"},
		// A token naming nothing and one since discarded answer alike.
		{"GET", "/draft/deadbeef", 404, "unknown draft"},
		{"POST", "/draft/deadbeef/send", 404, "send an unknown draft"},
		{"POST", "/draft/deadbeef/discard", 404, "discard an unknown draft"},
		{"GET", "/draft/deadbeef/send", 405, "send is POST only"},

		// The editor's API. Inert routes, so they are reachable by anything
		// that reaches the listen address; sending is deliberately not one.
		{"GET", "/api/drafts/deadbeef", 404, "unknown draft"},
		{"PATCH", "/api/drafts/deadbeef", 404, "patch an unknown draft"},
		{"POST", "/api/drafts/deadbeef/blocks", 404, "add to an unknown draft"},
		{"DELETE", "/api/drafts/deadbeef/blocks/1", 404, "delete from an unknown draft"},
		{"POST", "/api/drafts/deadbeef", 405, "the draft route takes GET and PATCH"},
		{"GET", "/static/draft-editor.js", 200, "the editor script"},
		{"GET", "/static/nosuch.js", 404, "no such static file"},

		{"GET", "/send", 405, "send is POST only"},
		{"GET", "/unsubscribe/1", 405, "unsubscribe is POST only"},
		// The more specific pattern must win, or an address would be parsed as
		// a message id.
		{"POST", "/unsubscribe/contact/a%40b.com", 404, "contact unsubscribe"},
		{"POST", "/unsubscribe/notanumber", 400, "non-numeric message id"},
	}

	for _, c := range cases {
		t.Run(c.method+" "+c.path, func(t *testing.T) {
			rec := httptest.NewRecorder()
			mux.ServeHTTP(rec, httptest.NewRequest(c.method, c.path, nil))
			if rec.Code != c.want {
				t.Errorf("%s %s (%s): got %d, want %d\n%s",
					c.method, c.path, c.note, rec.Code, c.want,
					strings.TrimSpace(rec.Body.String()))
			}
		})
	}
}

// TestRoutePathValues checks that the path variables arrive at the handler
// decoded, which is what replaced the url.PathUnescape each handler used to do.
func TestRoutePathValues(t *testing.T) {
	var got map[string]string
	mux := http.NewServeMux()
	record := func(keys ...string) http.HandlerFunc {
		return func(w http.ResponseWriter, r *http.Request) {
			got = map[string]string{}
			for _, k := range keys {
				got[k] = r.PathValue(k)
			}
		}
	}
	mux.HandleFunc("GET /contact/{addr}", record("addr"))
	mux.HandleFunc("GET /msg/{id}/part/{cid}", record("id", "cid"))

	for _, c := range []struct {
		path string
		want map[string]string
	}{
		{"/contact/klara%40example.org", map[string]string{"addr": "klara@example.org"}},
		// A slash inside a value survives, which a hand-rolled split on "/"
		// would have cut in half.
		{"/contact/we%2First%40example.org", map[string]string{"addr": "we/irst@example.org"}},
		{"/msg/12/part/img%40mail.example", map[string]string{"id": "12", "cid": "img@mail.example"}},
	} {
		mux.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", c.path, nil))
		for k, want := range c.want {
			if got[k] != want {
				t.Errorf("%s: %s = %q, want %q", c.path, k, got[k], want)
			}
		}
	}
}