Add the write page and a send's state, behind issue 1284's form token (issue 1267)
Wires 1284's per-session token into the real write form and its real POST /send; adds tests for 1284/AC1-AC4 against that real page and handler, beside this issue's own AC1-AC4.
This commit is contained in:
@@ -57,6 +57,9 @@ func NewServer(host string, auth *Auth, b *Bundle) *Server {
|
||||
s.Mux.HandleFunc("/search", protect(b.searchPage))
|
||||
s.Mux.HandleFunc("/mail", protect(b.mailPage))
|
||||
s.Mux.HandleFunc("/attachment", protect(b.attachmentPage))
|
||||
s.Mux.HandleFunc("/write", protect(b.writePage))
|
||||
s.Mux.HandleFunc("/send", protect(RequireFormToken(b.sendHandler)))
|
||||
s.Mux.HandleFunc("/send-status", protect(b.sendStatusPage))
|
||||
} else {
|
||||
s.Mux.HandleFunc("/", protect(s.rootPage))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package main
|
||||
|
||||
// The write page and a send's state (novox/hq ADR 0440 §6, issue 1267): the form offers only the accounts
|
||||
// mail-store.accounts answers; its POST (behind issue 1284's own per-session token, wired in here as this
|
||||
// form's hidden field) calls mail-store.send, which raises the operator's Approve on Telegram and sends
|
||||
// nothing itself, then shows the send's state from mail-store.send-status — in words, refreshing itself every
|
||||
// 5 seconds with no script (a meta refresh) until a final state (sent, failed, refused, not proven, declined,
|
||||
// expired, unknown; novox/hq ADR 0436 §12, issue 1261).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SendStatusRefreshEvery is how often the state page asks again while a send is still waiting (ADR 0440 §6).
|
||||
const SendStatusRefreshEvery = 5
|
||||
|
||||
var writeTemplate = template.Must(template.New("write").Parse(`<!DOCTYPE html>
|
||||
<html><head><title>Write mail</title></head><body>
|
||||
<h1>Write</h1>
|
||||
{{if .Error}}<p>{{.Error}}</p>{{else}}
|
||||
<form method="post" action="/send">
|
||||
<select name="account">{{range .Accounts}}<option value="{{.}}">{{.}}</option>{{end}}</select>
|
||||
<input type="text" name="to" placeholder="to">
|
||||
<input type="text" name="subject" placeholder="subject">
|
||||
<textarea name="body"></textarea>
|
||||
{{.TokenField}}
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</body></html>`))
|
||||
|
||||
type writeTemplateData struct {
|
||||
Accounts []string
|
||||
TokenField template.HTML
|
||||
Error string
|
||||
}
|
||||
|
||||
var sendStatusTemplate = template.Must(template.New("send-status").Parse(`<!DOCTYPE html>
|
||||
<html><head><title>Send</title>{{if .Refresh}}<meta http-equiv="refresh" content="{{.RefreshSeconds}}; url={{.RefreshURL}}">{{end}}</head>
|
||||
<body>
|
||||
{{if .Note}}<p>{{.Note}}</p>{{end}}
|
||||
<p>{{.Words}}</p>
|
||||
</body></html>`))
|
||||
|
||||
type sendStatusData struct {
|
||||
Note, Words, RefreshURL string
|
||||
RefreshSeconds int
|
||||
Refresh bool
|
||||
}
|
||||
|
||||
// grantedAccountNames is mail-store.accounts' own answer, read as the list of account names it granted —
|
||||
// shared by the write page (issue 1267/AC1) and the send handler (AC4).
|
||||
func (b *Bundle) grantedAccountNames(ctx context.Context) ([]string, error) {
|
||||
raw, err := b.accountsAsk(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var answer accountsAnswer
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(answer.Accounts))
|
||||
for _, a := range answer.Accounts {
|
||||
names = append(names, a.Account)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func containsName(names []string, name string) bool {
|
||||
for _, n := range names {
|
||||
if n == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitAddresses is the write form's own "to" field (one text input) split into mail-store.send's own array
|
||||
// argument, on whitespace or commas.
|
||||
func splitAddresses(s string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' }) {
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writePage is issue 1267/AC1: offers exactly the accounts mail-store.accounts answers, and (issue 1284) the
|
||||
// signed-in session's own form token as a hidden field, so the POST below can tell this session's own form
|
||||
// from any other's.
|
||||
func (b *Bundle) writePage(w http.ResponseWriter, r *http.Request) {
|
||||
data := writeTemplateData{}
|
||||
if sess, ok := SessionFrom(r); ok {
|
||||
data.TokenField = FormTokenInput(sess)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
names, err := b.grantedAccountNames(ctx)
|
||||
if err != nil {
|
||||
data.Error = "the granted accounts could not be answered"
|
||||
} else {
|
||||
data.Accounts = names
|
||||
}
|
||||
renderPage(w, writeTemplate, data)
|
||||
}
|
||||
|
||||
// sendHandler is issue 1267/AC2, AC4: called only once RequireFormToken (issue 1284) has already let the POST
|
||||
// through. An account mail-store did not answer sends nothing (AC4); otherwise mail-store.send is called with
|
||||
// exactly account, to, subject and body, and the answer is the send's own state page, noting that the
|
||||
// operator's Approve is asked on Telegram (AC2).
|
||||
func (b *Bundle) sendHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
account := r.PostFormValue("account")
|
||||
to := r.PostFormValue("to")
|
||||
subject := r.PostFormValue("subject")
|
||||
body := r.PostFormValue("body")
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
granted, err := b.grantedAccountNames(ctx)
|
||||
if err != nil || !containsName(granted, account) {
|
||||
renderPage(w, sendStatusTemplate, sendStatusData{Words: "the account is not granted"})
|
||||
return
|
||||
}
|
||||
raw, err := b.sendAsk(ctx, map[string]any{
|
||||
"account": account, "to": splitAddresses(to), "subject": subject, "body": body,
|
||||
})
|
||||
if err != nil {
|
||||
renderPage(w, sendStatusTemplate, sendStatusData{Words: "the send could not be asked"})
|
||||
return
|
||||
}
|
||||
var answer struct {
|
||||
Ask string `json:"ask"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &answer)
|
||||
b.renderSendStatus(w, answer.Ask, "The operator's Approve is asked on Telegram.")
|
||||
}
|
||||
|
||||
// sendStatusPage is GET /send-status?id=...: the send's own state page, polled by its own meta refresh (issue
|
||||
// 1267/AC3) until a final state.
|
||||
func (b *Bundle) sendStatusPage(w http.ResponseWriter, r *http.Request) {
|
||||
b.renderSendStatus(w, r.URL.Query().Get("id"), "")
|
||||
}
|
||||
|
||||
// renderSendStatus is issue 1267/AC3: the send's state in words from mail-store.send-status; "waiting" (not
|
||||
// yet final) refreshes itself every SendStatusRefreshEvery seconds with a meta refresh, no script; every
|
||||
// other state (sent, failed, refused, not proven, declined, expired, unknown) is final and does not refresh.
|
||||
func (b *Bundle) renderSendStatus(w http.ResponseWriter, id, note string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
data := sendStatusData{Note: note}
|
||||
raw, err := b.sendStatusAsk(ctx, map[string]any{"id": id})
|
||||
if err != nil {
|
||||
data.Words = "the send's state could not be answered"
|
||||
renderPage(w, sendStatusTemplate, data)
|
||||
return
|
||||
}
|
||||
var answer struct {
|
||||
Status string `json:"status"`
|
||||
Words string `json:"words"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &answer)
|
||||
data.Words = answer.Status
|
||||
if answer.Words != "" {
|
||||
data.Words += ": " + answer.Words
|
||||
}
|
||||
if answer.Status == "waiting" {
|
||||
data.Refresh = true
|
||||
data.RefreshSeconds = SendStatusRefreshEvery
|
||||
data.RefreshURL = "/send-status?id=" + url.QueryEscape(id)
|
||||
}
|
||||
renderPage(w, sendStatusTemplate, data)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package main
|
||||
|
||||
// The write page and a send's state (novox/hq ADR 0440 §6, issue 1267), and (beside 1267's own) issue 1284's
|
||||
// form token wired into this real write form and its real POST.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1267/AC1: mail-store answers accounts a and b; the write page offers exactly a and b to send from.
|
||||
func TestWritePageOffersExactlyTheGrantedAccounts(t *testing.T) {
|
||||
raw, _ := accountsAnswerJSON(
|
||||
map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"},
|
||||
map[string]any{"account": "b", "kind": "imap", "address": "b@example.org", "state": "current"},
|
||||
)
|
||||
b := &Bundle{Accounts: func(context.Context) (json.RawMessage, error) { return raw, nil }}
|
||||
w := httptest.NewRecorder()
|
||||
b.writePage(w, httptest.NewRequest(http.MethodGet, "/write", nil))
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `value="a"`) || !strings.Contains(body, `value="b"`) {
|
||||
t.Fatalf("the form does not offer a and b:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, `value="c"`) {
|
||||
t.Fatalf("the form offers an account not granted:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// writeBundle is a Bundle whose Accounts answers exactly the given names, each granted, for the write/send
|
||||
// tests below.
|
||||
func writeBundle(t *testing.T, names []string, send SeatAsk, sendStatus SeatAsk) *Bundle {
|
||||
t.Helper()
|
||||
accounts := make([]map[string]any, 0, len(names))
|
||||
for _, n := range names {
|
||||
accounts = append(accounts, map[string]any{"account": n, "kind": "imap", "address": n + "@example.org", "state": "current"})
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{"accounts": accounts})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Bundle{
|
||||
Accounts: func(context.Context) (json.RawMessage, error) { return raw, nil },
|
||||
Send: send,
|
||||
SendStatus: sendStatus,
|
||||
}
|
||||
}
|
||||
|
||||
// 1267/AC2: a POST with the session's token, from a, to, subject and body calls mail-store.send with exactly
|
||||
// account a, to, subject and body, and the page says the Approve is asked on Telegram and shows the send's
|
||||
// state.
|
||||
func TestSendHandlerCallsSendAndShowsTelegramNoteAndState(t *testing.T) {
|
||||
var gotArgs map[string]any
|
||||
b := writeBundle(t, []string{"a"},
|
||||
func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
gotArgs = args
|
||||
return json.Marshal(map[string]any{"ask": "send-1"})
|
||||
},
|
||||
func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
return json.Marshal(map[string]any{"status": "waiting", "words": "the operator has not answered yet"})
|
||||
},
|
||||
)
|
||||
form := url.Values{"account": {"a"}, "to": {"ops@example.org"}, "subject": {"hi"}, "body": {"the body"}}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodPost, "/send", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
b.sendHandler(w, r)
|
||||
|
||||
wantArgs := map[string]any{"account": "a", "to": []string{"ops@example.org"}, "subject": "hi", "body": "the body"}
|
||||
gotTo, _ := gotArgs["to"].([]string)
|
||||
if gotArgs["account"] != wantArgs["account"] || gotArgs["subject"] != wantArgs["subject"] || gotArgs["body"] != wantArgs["body"] ||
|
||||
len(gotTo) != 1 || gotTo[0] != "ops@example.org" {
|
||||
t.Fatalf("mail-store.send called with %#v", gotArgs)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "Telegram") {
|
||||
t.Fatalf("the page does not say the Approve is asked on Telegram:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "waiting") {
|
||||
t.Fatalf("the page does not show the send's state:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 1267/AC3: send-status answers a non-final state (waiting): the page says it and refreshes itself without
|
||||
// script; each final state (sent, failed, refused, not proven, declined, expired, unknown) says it and stops
|
||||
// refreshing.
|
||||
func TestSendStatusPageRefreshesOnlyWhileWaiting(t *testing.T) {
|
||||
cases := []struct {
|
||||
status string
|
||||
wantsRefresh bool
|
||||
}{
|
||||
{"waiting", true},
|
||||
{"sent", false}, {"failed", false}, {"refused", false}, {"not proven", false},
|
||||
{"declined", false}, {"expired", false}, {"unknown", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.status, func(t *testing.T) {
|
||||
b := &Bundle{SendStatus: func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
return json.Marshal(map[string]any{"status": c.status})
|
||||
}}
|
||||
w := httptest.NewRecorder()
|
||||
b.sendStatusPage(w, httptest.NewRequest(http.MethodGet, "/send-status?id=send-1", nil))
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, c.status) {
|
||||
t.Fatalf("%s: the page does not say the state:\n%s", c.status, body)
|
||||
}
|
||||
hasRefresh := strings.Contains(body, "http-equiv=\"refresh\"")
|
||||
if hasRefresh != c.wantsRefresh {
|
||||
t.Fatalf("%s: refreshes=%v, want %v:\n%s", c.status, hasRefresh, c.wantsRefresh, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 1267/AC4: a POST naming an account mail-store did not answer sends nothing, and the page says the account
|
||||
// is not granted.
|
||||
func TestSendHandlerRefusesAnAccountNotGranted(t *testing.T) {
|
||||
sendCalled := false
|
||||
b := writeBundle(t, []string{"a"},
|
||||
func(context.Context, map[string]any) (json.RawMessage, error) { sendCalled = true; return nil, nil },
|
||||
nil,
|
||||
)
|
||||
form := url.Values{"account": {"c"}, "to": {"ops@example.org"}, "subject": {"hi"}, "body": {"body"}}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodPost, "/send", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
b.sendHandler(w, r)
|
||||
|
||||
if sendCalled {
|
||||
t.Fatal("mail-store.send was called for an account not granted")
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "not granted") {
|
||||
t.Fatalf("the page does not say the account is not granted:\n%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- issue 1284/AC1-AC4, against the real write page and its real POST /send (beside 1267's own tests
|
||||
// above, and beside csrf_test.go's stand-in handler tests, as the brief asked) --------------------------------
|
||||
|
||||
// writeServerBundle wires the real write page and send handler behind a real Auth (RequireSession,
|
||||
// RequireFormToken), exactly as server.go's NewServer does, so these tests exercise the production chain, not
|
||||
// a stand-in.
|
||||
func writeServerBundle(t *testing.T, names []string, send SeatAsk) (*Bundle, *Sessions, *Auth) {
|
||||
t.Helper()
|
||||
b := writeBundle(t, names, send, nil)
|
||||
sessions := NewSessions(nil)
|
||||
auth := &Auth{Sessions: sessions}
|
||||
return b, sessions, auth
|
||||
}
|
||||
|
||||
// 1284/AC1: a signed-in session's write page carries that session's own token.
|
||||
func TestRealWritePageFormCarriesTheSessionsToken(t *testing.T) {
|
||||
b, sessions, auth := writeServerBundle(t, []string{"a"}, nil)
|
||||
sess, cookie := sessionAndCookie(t, sessions, "alice")
|
||||
handler := auth.RequireSession(b.writePage)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/write", nil)
|
||||
r.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
handler(w, r)
|
||||
|
||||
if !strings.Contains(w.Body.String(), sess.Token) {
|
||||
t.Fatalf("the real write page's form does not carry the session's token:\n%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 1284/AC2: a real POST /send without the token is answered 403, and mail-store.send is not called.
|
||||
func TestRealSendPOSTWithoutTheTokenIsRefused(t *testing.T) {
|
||||
var sendCalled bool
|
||||
b, sessions, auth := writeServerBundle(t, []string{"a"}, func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
sendCalled = true
|
||||
return nil, errors.New("should not be called")
|
||||
})
|
||||
_, cookie := sessionAndCookie(t, sessions, "alice")
|
||||
handler := auth.RequireSession(RequireFormToken(b.sendHandler))
|
||||
|
||||
form := url.Values{"account": {"a"}, "to": {"x@example.org"}, "subject": {"s"}, "body": {"b"}}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodPost, "/send", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.AddCookie(cookie)
|
||||
handler(w, r)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status %d, want 403", w.Code)
|
||||
}
|
||||
if sendCalled {
|
||||
t.Fatal("mail-store.send was called with no token")
|
||||
}
|
||||
}
|
||||
|
||||
// 1284/AC3: a real POST /send carrying another session's token is answered 403, and mail-store.send is not
|
||||
// called.
|
||||
func TestRealSendPOSTWithAnotherSessionsTokenIsRefused(t *testing.T) {
|
||||
var sendCalled bool
|
||||
b, sessions, auth := writeServerBundle(t, []string{"a"}, func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
sendCalled = true
|
||||
return nil, errors.New("should not be called")
|
||||
})
|
||||
_, cookie := sessionAndCookie(t, sessions, "alice")
|
||||
other, _ := sessionAndCookie(t, sessions, "bob")
|
||||
handler := auth.RequireSession(RequireFormToken(b.sendHandler))
|
||||
|
||||
form := url.Values{"account": {"a"}, "to": {"x@example.org"}, "subject": {"s"}, "body": {"b"}, FormTokenField: {other.Token}}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodPost, "/send", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.AddCookie(cookie)
|
||||
handler(w, r)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status %d, want 403", w.Code)
|
||||
}
|
||||
if sendCalled {
|
||||
t.Fatal("mail-store.send was called with another session's token")
|
||||
}
|
||||
}
|
||||
|
||||
// 1284/AC4: a real POST /send carrying its own session's token proceeds — mail-store.send is called.
|
||||
func TestRealSendPOSTWithItsOwnSessionsTokenProceeds(t *testing.T) {
|
||||
var sendCalled bool
|
||||
b, sessions, auth := writeServerBundle(t, []string{"a"}, func(context.Context, map[string]any) (json.RawMessage, error) {
|
||||
sendCalled = true
|
||||
return json.Marshal(map[string]any{"ask": "send-1"})
|
||||
})
|
||||
sess, cookie := sessionAndCookie(t, sessions, "alice")
|
||||
handler := auth.RequireSession(RequireFormToken(b.sendHandler))
|
||||
|
||||
form := url.Values{"account": {"a"}, "to": {"x@example.org"}, "subject": {"s"}, "body": {"b"}, FormTokenField: {sess.Token}}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodPost, "/send", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.AddCookie(cookie)
|
||||
handler(w, r)
|
||||
|
||||
if !sendCalled {
|
||||
t.Fatal("mail-store.send was not called despite the matching token")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user