issue 1859: tick, undo and remind an item, and choose a mode, from the page (1859.3)
ADR 0517 §1, §2, §4, §5: /item and /mode are posts behind the session's token, each calling its one mail-reading verb (handle, unhandle, remind, record) for the operator; the script gets done or failed with the cause as JSON, a plain form goes back to the list or shows it again saying what failed and why.
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
package web
|
||||
|
||||
// What the operator does on the mail page (novox/hq ADR 0517 §1, §2, §4, §5; issue 1859): tick an item done,
|
||||
// undo it, remind it tomorrow, choose a mode. Each is a post RequireFormToken has already let through (the
|
||||
// session's own token, csrf.go), calling its one mail-reading verb for the signed-in operator; the page keeps
|
||||
// no state of its own (ADR 0440 §4). Posted by the page's script (X-Requested-With: fetch), it answers JSON:
|
||||
// done, where to go, or failed with the cause. Posted by a plain form, it goes back to the list, or shows the
|
||||
// list again saying the act failed and why — never dropped silently.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// byScript says whether the page's own script posted the form.
|
||||
func byScript(r *http.Request) bool { return r.Header.Get("X-Requested-With") == "fetch" }
|
||||
|
||||
func answerJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// actFailed says an act failed, with its cause: JSON to the script, the list page with the cause otherwise.
|
||||
func (b *Bundle) actFailed(w http.ResponseWriter, r *http.Request, mode Mode, cause string) {
|
||||
if byScript(r) {
|
||||
answerJSON(w, http.StatusBadGateway, map[string]any{"failed": cause})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
data := b.listDataQuiet(ctx, r, mode)
|
||||
data.Notice = cause
|
||||
renderNamed(w, http.StatusBadGateway, "list", data)
|
||||
}
|
||||
|
||||
// actDone answers an act that succeeded: done to the script, or back to the page at back.
|
||||
func actDone(w http.ResponseWriter, r *http.Request, back string) {
|
||||
if byScript(r) {
|
||||
answerJSON(w, http.StatusOK, map[string]any{"done": true})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, back, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// listAddress is the list page's own address for mode.
|
||||
func listAddress(mode Mode) string { return "/?" + url.Values{"mode": {mode.Name}}.Encode() }
|
||||
|
||||
// itemHandler is POST /item (1859/AC5): act handle, unhandle or remind, on the item (a task, a promise, or
|
||||
// the mail itself) of the mail entry id, in the mode the page is in.
|
||||
func (b *Bundle) itemHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "post only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
mode := modeNamed(r.PostFormValue("mode"))
|
||||
id, err := strconv.Atoi(r.PostFormValue("id"))
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, "an item needs its mail entry's id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ask := map[string]func(context.Context, map[string]any) (json.RawMessage, error){
|
||||
"handle": b.mailHandleAsk, "unhandle": b.mailUnhandleAsk, "remind": b.mailRemindAsk,
|
||||
}[r.PostFormValue("act")]
|
||||
if ask == nil {
|
||||
http.Error(w, "the act is none of handle, unhandle, remind", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
args := map[string]any{"id": float64(id), "mode": mode.Name}
|
||||
if item := r.PostFormValue("item"); item != "" {
|
||||
args["item"] = item
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
if _, err := ask(ctx, args); err != nil {
|
||||
b.actFailed(w, r, mode, "That did not happen: "+err.Error())
|
||||
return
|
||||
}
|
||||
back := r.PostFormValue("back")
|
||||
if back == "" || back[0] != '/' || (len(back) > 1 && back[1] == '/') {
|
||||
back = listAddress(mode) // only an address of this page itself
|
||||
}
|
||||
actDone(w, r, back)
|
||||
}
|
||||
|
||||
// modeHandler is POST /mode (1859/AC2, AC5): the mode chosen, recorded (ADR 0517 §4), then its list.
|
||||
func (b *Bundle) modeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "post only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
mode := modeNamed(r.PostFormValue("mode"))
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
if _, err := b.mailRecordAsk(ctx, map[string]any{"what": "mode", "mode": mode.Name}); err != nil {
|
||||
b.actFailed(w, r, mode, "The mode chosen could not be kept: "+err.Error())
|
||||
return
|
||||
}
|
||||
if byScript(r) {
|
||||
answerJSON(w, http.StatusOK, map[string]any{"done": true, "go": listAddress(mode)})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, listAddress(mode), http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package web
|
||||
|
||||
// Issue 1859/AC5 (novox/hq ADR 0517 §1, §2, §5): ticking, undoing and reminding an item, and choosing a mode,
|
||||
// are posts carrying the session's token; each calls its mail-reading verb and answers the new state; without
|
||||
// the token it is refused 403 and nothing is called; a verb that fails is said on the page with its cause.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// signedIn is a server over b with one live session: its cookie and its form token.
|
||||
func signedIn(t *testing.T, b *Bundle) (*Server, *http.Cookie, string) {
|
||||
t.Helper()
|
||||
sessions := NewSessions(nil)
|
||||
id, err := sessions.Create("operator")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sess, _ := sessions.Get(id)
|
||||
return NewServer(testHost, &Auth{Sessions: sessions, Pending: NewPendingAuths(nil)}, b),
|
||||
&http.Cookie{Name: SessionCookie, Value: id}, sess.Token
|
||||
}
|
||||
|
||||
func post(t *testing.T, h http.Handler, path string, form url.Values, cookie *http.Cookie, fetch bool) (int, string, http.Header) {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||
r.Host = testHost
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if cookie != nil {
|
||||
r.AddCookie(cookie)
|
||||
}
|
||||
if fetch {
|
||||
r.Header.Set("X-Requested-With", "fetch")
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
body, _ := io.ReadAll(w.Result().Body)
|
||||
return w.Code, string(body), w.Result().Header
|
||||
}
|
||||
|
||||
// 1859/AC5.
|
||||
func TestTickingUndoingAndRemindingCallTheirVerbs(t *testing.T) {
|
||||
for _, act := range []string{"handle", "unhandle", "remind"} {
|
||||
t.Run(act, func(t *testing.T) {
|
||||
reading := &readingStandIn{list: fullList()}
|
||||
s, cookie, token := signedIn(t, reading.bundle(time.Now()))
|
||||
form := url.Values{"id": {"3"}, "item": {"task 1"}, "mode": {"work"}, "act": {act}, FormTokenField: {token}}
|
||||
|
||||
code, _, h := post(t, s, "/item", form, cookie, false)
|
||||
calls := reading.callsOf(act)
|
||||
if code != http.StatusSeeOther || h.Get("Location") != "/?mode=work" || len(calls) != 1 ||
|
||||
calls[0].Args["id"] != float64(3) || calls[0].Args["item"] != "task 1" || calls[0].Args["mode"] != "work" {
|
||||
t.Fatalf("a post of %s: status %d to %q, calls %v; want 303 to /?mode=work and one call", act, code, h.Get("Location"), calls)
|
||||
}
|
||||
code, body, _ := post(t, s, "/item", form, cookie, true)
|
||||
if code != http.StatusOK || !strings.Contains(body, `"done":true`) || len(reading.callsOf(act)) != 2 {
|
||||
t.Fatalf("the script's post of %s: status %d %s", act, code, body)
|
||||
}
|
||||
|
||||
form.Del(FormTokenField)
|
||||
if code, _, _ := post(t, s, "/item", form, cookie, false); code != http.StatusForbidden || len(reading.callsOf(act)) != 2 {
|
||||
t.Fatalf("a post with no token: status %d, %d calls; want 403 and no call", code, len(reading.callsOf(act)))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 1859/AC5: a verb that fails is said on the page as failed, with its cause, both with and without the script.
|
||||
func TestAnActThatFailsIsSaidWithItsCause(t *testing.T) {
|
||||
reading := &readingStandIn{list: fullList(), fail: map[string]error{"handle": errors.New("the mail entry 3 holds no task 1")}}
|
||||
s, cookie, token := signedIn(t, reading.bundle(time.Now()))
|
||||
form := url.Values{"id": {"3"}, "item": {"task 1"}, "mode": {"everything"}, "act": {"handle"}, FormTokenField: {token}}
|
||||
code, body, _ := post(t, s, "/item", form, cookie, false)
|
||||
if code != http.StatusBadGateway || !strings.Contains(body, `class="notice failed"`) || !strings.Contains(body, "holds no task 1") ||
|
||||
!strings.Contains(body, `id="today"`) {
|
||||
t.Fatalf("a failed tick: status %d, want 502 and the list saying the cause:\n%s", code, body)
|
||||
}
|
||||
code, body, _ = post(t, s, "/item", form, cookie, true)
|
||||
if code != http.StatusBadGateway || !strings.Contains(body, `"failed":`) || !strings.Contains(body, "holds no task 1") {
|
||||
t.Fatalf("a failed tick by the script: status %d %s", code, body)
|
||||
}
|
||||
form.Set("act", "delete")
|
||||
if code, _, _ := post(t, s, "/item", form, cookie, false); code != http.StatusBadRequest {
|
||||
t.Fatalf("an act that is none of handle, unhandle, remind: status %d, want 400", code)
|
||||
}
|
||||
}
|
||||
|
||||
// 1859/AC5, AC2: choosing a mode records it (ADR 0517 §4) and goes to that mode's list.
|
||||
func TestChoosingAModeRecordsItAndShowsIt(t *testing.T) {
|
||||
reading := &readingStandIn{list: fullList()}
|
||||
s, cookie, token := signedIn(t, reading.bundle(time.Now()))
|
||||
code, _, h := post(t, s, "/mode", url.Values{"mode": {"personal"}, FormTokenField: {token}}, cookie, false)
|
||||
rec := reading.callsOf("record")
|
||||
if code != http.StatusSeeOther || h.Get("Location") != "/?mode=personal" || len(rec) != 1 ||
|
||||
rec[0].Args["what"] != "mode" || rec[0].Args["mode"] != "personal" {
|
||||
t.Fatalf("choosing personal: status %d to %q, records %v", code, h.Get("Location"), rec)
|
||||
}
|
||||
code, body, _ := post(t, s, "/mode", url.Values{"mode": {"work"}, FormTokenField: {token}}, cookie, true)
|
||||
if code != http.StatusOK || !strings.Contains(body, `"go":"/?mode=work"`) {
|
||||
t.Fatalf("choosing work by the script: status %d %s", code, body)
|
||||
}
|
||||
}
|
||||
@@ -289,6 +289,15 @@ func (b *Bundle) statusLine(ctx context.Context) string {
|
||||
// listData builds the list page's data for mode: asks mail-reading's status and list (for the mode's accounts,
|
||||
// modes.go), and says the page opened (record, ADR 0517 §4) — a failure of that is said, never dropped.
|
||||
func (b *Bundle) listData(ctx context.Context, r *http.Request, mode Mode) listPageData {
|
||||
return b.listDataOf(ctx, r, mode, true)
|
||||
}
|
||||
|
||||
// listDataQuiet is listData without saying the page opened: the page shown again after an act.
|
||||
func (b *Bundle) listDataQuiet(ctx context.Context, r *http.Request, mode Mode) listPageData {
|
||||
return b.listDataOf(ctx, r, mode, false)
|
||||
}
|
||||
|
||||
func (b *Bundle) listDataOf(ctx context.Context, r *http.Request, mode Mode, opened bool) listPageData {
|
||||
now := b.now()
|
||||
data := listPageData{Title: "Mail", Mode: mode.Name, Greeting: greeting(now)}
|
||||
for _, m := range Modes {
|
||||
@@ -344,6 +353,9 @@ func (b *Bundle) listData(ctx context.Context, r *http.Request, mode Mode) listP
|
||||
if answer.LastLooked != "" {
|
||||
data.LastLooked = localTime(answer.LastLooked, now)
|
||||
}
|
||||
if !opened {
|
||||
return data
|
||||
}
|
||||
if _, err := b.mailRecordAsk(ctx, map[string]any{"what": "page opened", "mode": mode.Name}); err != nil {
|
||||
data.Notice = "That you opened the page could not be kept: " + err.Error()
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ func NewServer(host string, auth *Auth, b *Bundle) *Server {
|
||||
if b != nil {
|
||||
s.Mux.HandleFunc("/", protect(b.listPage))
|
||||
s.Mux.HandleFunc("/all", protect(b.accountsPage))
|
||||
s.Mux.HandleFunc("/item", protect(RequireFormToken(b.itemHandler)))
|
||||
s.Mux.HandleFunc("/mode", protect(RequireFormToken(b.modeHandler)))
|
||||
s.Mux.HandleFunc("/static/", staticPage) // the script and the stylesheet carry nothing of a mail
|
||||
s.Mux.HandleFunc("/folder", protect(b.folderPage))
|
||||
s.Mux.HandleFunc("/search", protect(b.searchPage))
|
||||
|
||||
Reference in New Issue
Block a user