Add design 59 milestone 1's acceptance tests on the runtime player (issue 1268)
Four scenarios, entered only through HTTP with Host mail.<node>.internal as a browser would, against the built mesh-mail-web, a stand-in identity provider (auth_test.go's own fakeIssuer, reused) and a real account bucket holding granted account a and ungranted account c: sign-in redirects, 403s and the session cookie's attributes; the 404 for another host; browsing and a send followed to sent, only the five mail verbs asked, account c never shown, paging by date, no database or extra writable directory; a mail's script and remote image shown as text, an attachment downloaded with nosniff, and the CSP on every answer. Build tag integration; compiles under go vet -tags integration ./...; never run here.
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
// Design 59 milestone 1's acceptance tests, 1 to 4 (novox/hq ADR 0440; issue 1268), on the harness issue
|
||||
// 1285 built: mesh-mail-web started whole, a stand-in identity provider (the same httptest issuer as
|
||||
// auth_test.go's own unit tests, reused here), and an account bucket holding a granted account a and an
|
||||
// ungranted account c. Every scenario enters only through HTTP to the started process, Host
|
||||
// mail.<node>.internal, exactly as a browser (through the route proxy) would — never by calling a handler
|
||||
// directly. Compiled by `go vet -tags integration ./...`; never run here (this batch's hard rule) — the
|
||||
// lead's try runs it for real.
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// acceptanceAccounts is the account bucket design test 3 needs: a (granted, with 55 mails in INBOX, so the
|
||||
// folder page's own paging shows), c (never answered by mail-store, so never shown).
|
||||
func acceptanceAccounts() map[string]accountBucketValue {
|
||||
return map[string]accountBucketValue{
|
||||
"a": {State: "current", Folders: []accountBucketFolder{{Name: "INBOX", Mails: 55, Unread: 2}}},
|
||||
"c": {State: "current", Folders: []accountBucketFolder{{Name: "Secret", Mails: 1, Unread: 1}}},
|
||||
}
|
||||
}
|
||||
|
||||
// acceptanceMails is 55 fake mails of account a's INBOX, newest first, each a minute apart — enough for the
|
||||
// folder page's own 50-a-page paging (issue 1264/AC3) to show a second page.
|
||||
func acceptanceMails(base time.Time) []map[string]any {
|
||||
out := make([]map[string]any, 0, 55)
|
||||
for i := 0; i < 55; i++ {
|
||||
out = append(out, map[string]any{
|
||||
"account": "a", "folder": "INBOX", "uid": 55 - i,
|
||||
"date": base.Add(-time.Duration(i) * time.Minute).UTC().Format(time.RFC3339),
|
||||
"from": []map[string]any{{"address": "sender@example.org"}},
|
||||
"subject": "mail " + strconv.Itoa(55-i),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// searchStandIn answers mail-store.search over mails, filtering by account, folder and before exactly as
|
||||
// mesh-mail's own Search would (newest first, at most limit), so the folder page's own "before" paging
|
||||
// (issue 1264/AC3) is genuinely exercised, not merely trusted.
|
||||
func searchStandIn(mails []map[string]any) func(args map[string]any) (any, error) {
|
||||
return func(args map[string]any) (any, error) {
|
||||
account, _ := args["account"].(string)
|
||||
folder, _ := args["folder"].(string)
|
||||
before, _ := args["before"].(string)
|
||||
limit := 50
|
||||
if l, ok := args["limit"].(float64); ok {
|
||||
limit = int(l)
|
||||
}
|
||||
var beforeTime time.Time
|
||||
if before != "" {
|
||||
beforeTime, _ = time.Parse(time.RFC3339, before)
|
||||
}
|
||||
var found []map[string]any
|
||||
for _, m := range mails {
|
||||
if account != "" && m["account"] != account {
|
||||
continue
|
||||
}
|
||||
if folder != "" && m["folder"] != folder {
|
||||
continue
|
||||
}
|
||||
if !beforeTime.IsZero() {
|
||||
d, _ := time.Parse(time.RFC3339, m["date"].(string))
|
||||
if !d.Before(beforeTime) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
found = append(found, m)
|
||||
if len(found) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return map[string]any{"found": found}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// readStandIn answers mail-store.read: with `attachment`, the attachment's own bytes (design test 4); without
|
||||
// it, a mail whose body holds a script element and a remote image, and names that attachment (design tests
|
||||
// 3 and 4 share this one mail).
|
||||
func readStandIn(args map[string]any) (any, error) {
|
||||
if key, _ := args["attachment"].(string); key != "" {
|
||||
return map[string]any{
|
||||
"attachment": map[string]any{"key": key, "name": "invoice.pdf", "size": 11, "type": "application/pdf"},
|
||||
"base64": "aGVsbG8gd29ybGQ=", // "hello world"
|
||||
}, nil
|
||||
}
|
||||
return map[string]any{
|
||||
"account": "a", "folder": "INBOX", "uid": 1, "subject": "hi",
|
||||
"date": time.Now().UTC().Format(time.RFC3339),
|
||||
"from": []map[string]any{{"address": "sender@example.org"}},
|
||||
"to": []map[string]any{{"address": "a@example.org"}},
|
||||
"text": `<script>alert(1)</script> and <img src="http://remote.example/x.png">`,
|
||||
"attachments": []map[string]any{
|
||||
{"key": "att1", "name": "invoice.pdf", "size": 11, "type": "application/pdf"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sendStandIn answers mail-store.send once with a fixed ask id, and mail-store.send-status "waiting" the
|
||||
// first time it is asked, "sent" from then on — so the write page's own flow (issue 1267) is followed from
|
||||
// its POST to a final state exactly as a browser's own meta-refresh polling would (design test 3).
|
||||
type sendStandIn struct{ statusCalls int }
|
||||
|
||||
func (s *sendStandIn) send(map[string]any) (any, error) {
|
||||
return map[string]any{"ask": "send-1"}, nil
|
||||
}
|
||||
|
||||
func (s *sendStandIn) status(map[string]any) (any, error) {
|
||||
s.statusCalls++
|
||||
if s.statusCalls == 1 {
|
||||
return map[string]any{"status": "waiting", "words": "the operator has not answered yet"}, nil
|
||||
}
|
||||
return map[string]any{"status": "sent", "words": "sent on the operator's Approve"}, nil
|
||||
}
|
||||
|
||||
// 1268/design test 1 (AC1): no session redirects a page, an attachment and a send to sign-in; an identity
|
||||
// not in operators is 403; a send without its token is 403; the session cookie is Secure, HttpOnly,
|
||||
// SameSite=Strict, 12 hours.
|
||||
func TestAcceptanceSignInRedirectsAndRefusals(t *testing.T) {
|
||||
mw := startMailWeb(t, acceptanceAccounts(), []string{"alice"})
|
||||
mw.store.Accounts = func(map[string]any) (any, error) {
|
||||
return map[string]any{"accounts": []any{map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"}}}, nil
|
||||
}
|
||||
|
||||
for _, path := range []string{"/", "/attachment?account=a&folder=INBOX&uid=1&key=att1", "/write"} {
|
||||
resp := doWithCookie(t, mw, http.MethodGet, path, nil, nil)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("%s with no session: status %d, want 302 (redirect to sign in)", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
sendResp := doForm(t, mw, "/send", url.Values{}, nil)
|
||||
_ = sendResp.Body.Close()
|
||||
if sendResp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("POST /send with no session: status %d, want 302", sendResp.StatusCode)
|
||||
}
|
||||
|
||||
// An identity not in operators: 403, no session cookie.
|
||||
req, err := http.NewRequest(http.MethodGet, "http://"+mw.internalHost+"/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Host = mw.internalHost
|
||||
resp, err := mw.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
loc, err := url.Parse(resp.Header.Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mw.fakeIssuer.nextIDToken = mw.fakeIssuer.idToken(testClientID, "mallory", time.Now(), nil, mw.fakeIssuer.key, mw.fakeIssuer.kid)
|
||||
cbReq, err := http.NewRequest(http.MethodGet,
|
||||
"http://"+mw.internalHost+SignedInPath+"?state="+url.QueryEscape(loc.Query().Get("state"))+"&code=c", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cbReq.Host = mw.internalHost
|
||||
cbResp, err := mw.client.Do(cbReq)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cbResp.Body.Close()
|
||||
if cbResp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("an identity not in operators: status %d, want 403", cbResp.StatusCode)
|
||||
}
|
||||
for _, c := range cbResp.Cookies() {
|
||||
if c.Name == SessionCookie {
|
||||
t.Fatal("a session cookie was set for an identity not in operators")
|
||||
}
|
||||
}
|
||||
|
||||
// A signed-in operator's send without the token: 403.
|
||||
cookie := signIn(t, mw, "alice", "/")
|
||||
if !cookie.Secure || !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode {
|
||||
t.Fatalf("cookie attributes: Secure=%v HttpOnly=%v SameSite=%v", cookie.Secure, cookie.HttpOnly, cookie.SameSite)
|
||||
}
|
||||
if cookie.MaxAge != int(SessionLifetime/time.Second) {
|
||||
t.Fatalf("cookie MaxAge %d, want %d (12h)", cookie.MaxAge, int(SessionLifetime/time.Second))
|
||||
}
|
||||
|
||||
noTokenResp := doForm(t, mw, "/send", url.Values{"account": {"a"}, "to": {"x@example.org"}, "subject": {"s"}, "body": {"b"}}, cookie)
|
||||
defer noTokenResp.Body.Close()
|
||||
if noTokenResp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("a send with no form token: status %d, want 403", noTokenResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// 1268/design test 2 (AC2): a request with any other Host is answered 404.
|
||||
func TestAcceptanceOtherHostIs404(t *testing.T) {
|
||||
mw := startMailWeb(t, nil, nil)
|
||||
req, err := http.NewRequest(http.MethodGet, "http://"+mw.internalHost+"/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Host = "example.com"
|
||||
resp, err := mw.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("Host example.com: status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// 1268/design test 3 (AC3): a signed-in operator browsing the accounts page, a folder's two pages, a search,
|
||||
// a mail and a send followed to sent — the stand-in recorded only the five mail verbs; folders appear only
|
||||
// for account a; the second folder page asked before the last date shown; the send's own page reached sent;
|
||||
// the manifest declares no database and no writable directory beyond the state directory.
|
||||
func TestAcceptanceBrowsingAndSendingEndToEnd(t *testing.T) {
|
||||
mw := startMailWeb(t, acceptanceAccounts(), []string{"alice"})
|
||||
mw.store.Accounts = func(map[string]any) (any, error) {
|
||||
return map[string]any{"accounts": []any{map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"}}}, nil
|
||||
}
|
||||
mails := acceptanceMails(time.Now())
|
||||
mw.store.Search = searchStandIn(mails)
|
||||
mw.store.Read = readStandIn
|
||||
sends := &sendStandIn{}
|
||||
mw.store.Send = sends.send
|
||||
mw.store.SendStatus = sends.status
|
||||
|
||||
cookie := signIn(t, mw, "alice", "/")
|
||||
|
||||
// The accounts page: a appears, c (ungranted) never does.
|
||||
accResp := doWithCookie(t, mw, http.MethodGet, "/", nil, cookie)
|
||||
accBody, _ := io.ReadAll(accResp.Body)
|
||||
_ = accResp.Body.Close()
|
||||
if accResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("accounts page: status %d: %s", accResp.StatusCode, accBody)
|
||||
}
|
||||
if !strings.Contains(string(accBody), "INBOX") {
|
||||
t.Fatalf("the accounts page does not show account a's folder:\n%s", accBody)
|
||||
}
|
||||
if strings.Contains(string(accBody), "Secret") {
|
||||
t.Fatalf("the accounts page shows account c's folder, though it was never granted:\n%s", accBody)
|
||||
}
|
||||
|
||||
// The folder's first page: 50 mails, a next link to the 51st-55th.
|
||||
page1 := doWithCookie(t, mw, http.MethodGet, "/folder?account=a&folder=INBOX", nil, cookie)
|
||||
page1Body, _ := io.ReadAll(page1.Body)
|
||||
_ = page1.Body.Close()
|
||||
fiftieth := mails[49]["date"].(string)
|
||||
// html/template percent-encodes an href's own query value (":" becomes "%3a" etc — issue 1264's own
|
||||
// unit test, TestFolderPageShows50NewestAndLinksToTheNextPageByDate, hit the same shape): compared only
|
||||
// up to the date's own colon-free prefix, never byte-for-byte against the raw RFC3339 string.
|
||||
datePrefix := strings.SplitN(fiftieth, ":", 2)[0] // "2026-01-02T12"
|
||||
if !strings.Contains(strings.ToLower(string(page1Body)), strings.ToLower("before="+datePrefix)) {
|
||||
t.Fatalf("the folder's first page does not link to the next page before the 50th mail's date (%s):\n%s", fiftieth, page1Body)
|
||||
}
|
||||
|
||||
// The folder's second page: asked with that same before.
|
||||
page2 := doWithCookie(t, mw, http.MethodGet, "/folder?account=a&folder=INBOX&before="+url.QueryEscape(fiftieth), nil, cookie)
|
||||
page2Body, _ := io.ReadAll(page2.Body)
|
||||
_ = page2.Body.Close()
|
||||
if page2.StatusCode != http.StatusOK {
|
||||
t.Fatalf("the folder's second page: status %d: %s", page2.StatusCode, page2Body)
|
||||
}
|
||||
if !strings.Contains(string(page2Body), "mail 5") { // the 55th-50th mails' own subjects
|
||||
t.Fatalf("the folder's second page does not show the older mails:\n%s", page2Body)
|
||||
}
|
||||
|
||||
// A search.
|
||||
searchResp := doWithCookie(t, mw, http.MethodGet, "/search?query=mail", nil, cookie)
|
||||
_ = searchResp.Body.Close()
|
||||
if searchResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("the search page: status %d", searchResp.StatusCode)
|
||||
}
|
||||
|
||||
// A mail, and its attachment.
|
||||
mailResp := doWithCookie(t, mw, http.MethodGet, "/mail?account=a&folder=INBOX&uid=1", nil, cookie)
|
||||
mailBody, _ := io.ReadAll(mailResp.Body)
|
||||
_ = mailResp.Body.Close()
|
||||
if mailResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("the mail page: status %d: %s", mailResp.StatusCode, mailBody)
|
||||
}
|
||||
attResp := doWithCookie(t, mw, http.MethodGet, "/attachment?account=a&folder=INBOX&uid=1&key=att1", nil, cookie)
|
||||
attBody, _ := io.ReadAll(attResp.Body)
|
||||
_ = attResp.Body.Close()
|
||||
if attResp.StatusCode != http.StatusOK || string(attBody) != "hello world" {
|
||||
t.Fatalf("the attachment: status %d, body %q", attResp.StatusCode, attBody)
|
||||
}
|
||||
|
||||
// The write page's own form token, then a send followed to sent.
|
||||
writeResp := doWithCookie(t, mw, http.MethodGet, "/write", nil, cookie)
|
||||
writeBody, _ := io.ReadAll(writeResp.Body)
|
||||
_ = writeResp.Body.Close()
|
||||
token := extractHiddenValue(string(writeBody), FormTokenField)
|
||||
if token == "" {
|
||||
t.Fatalf("the write page carries no form token:\n%s", writeBody)
|
||||
}
|
||||
sendResp := doForm(t, mw, "/send", url.Values{
|
||||
"account": {"a"}, "to": {"ops@example.org"}, "subject": {"hi"}, "body": {"the body"}, FormTokenField: {token},
|
||||
}, cookie)
|
||||
sendBody, _ := io.ReadAll(sendResp.Body)
|
||||
_ = sendResp.Body.Close()
|
||||
if sendResp.StatusCode != http.StatusOK || !strings.Contains(string(sendBody), "waiting") {
|
||||
t.Fatalf("the send's first answer: status %d: %s", sendResp.StatusCode, sendBody)
|
||||
}
|
||||
statusResp := doWithCookie(t, mw, http.MethodGet, "/send-status?id=send-1", nil, cookie)
|
||||
statusBody, _ := io.ReadAll(statusResp.Body)
|
||||
_ = statusResp.Body.Close()
|
||||
if !strings.Contains(string(statusBody), "sent") {
|
||||
t.Fatalf("the send's state did not reach sent:\n%s", statusBody)
|
||||
}
|
||||
if strings.Contains(string(statusBody), `http-equiv="refresh"`) {
|
||||
t.Fatalf("the send's final page still refreshes itself:\n%s", statusBody)
|
||||
}
|
||||
|
||||
// Only the five mail verbs were ever asked.
|
||||
wantKeys := map[string]bool{
|
||||
"seat:mail-store.accounts": true, "seat:mail-store.search": true, "seat:mail-store.read": true,
|
||||
"seat:mail-store.send": true, "seat:mail-store.send-status": true,
|
||||
}
|
||||
for _, k := range mw.store.Keys() {
|
||||
if !wantKeys[k] {
|
||||
t.Fatalf("an unexpected verb was asked: %s", k)
|
||||
}
|
||||
}
|
||||
|
||||
// The manifest declares no database and no writable directory beyond the state directory (reusing
|
||||
// manifest_test.go's own reader, this package's always-compiled parser).
|
||||
m := readManifest(t)
|
||||
for _, dbish := range []string{"postgres-database", "s3-bucket"} {
|
||||
if contains(m.Requires, dbish) {
|
||||
t.Fatalf("the manifest requires %s, a database or object store", dbish)
|
||||
}
|
||||
}
|
||||
wantResourceIDs := map[string]bool{"account": true, "state": true, "operators": true, "oidc-config": true}
|
||||
for _, r := range m.Resources {
|
||||
id, _ := r["id"].(string)
|
||||
if !wantResourceIDs[id] {
|
||||
t.Fatalf("an unexpected resource %q: no writable directory beyond the state dir is wanted", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1268/design test 4 (AC4): a mail with a script element and a remote image is shown as text; an attachment
|
||||
// comes as a download with nosniff; every page answered carries the content security policy.
|
||||
func TestAcceptanceMailEscapingAttachmentDownloadAndCSP(t *testing.T) {
|
||||
mw := startMailWeb(t, acceptanceAccounts(), []string{"alice"})
|
||||
mw.store.Accounts = func(map[string]any) (any, error) {
|
||||
return map[string]any{"accounts": []any{map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"}}}, nil
|
||||
}
|
||||
mw.store.Read = readStandIn
|
||||
cookie := signIn(t, mw, "alice", "/")
|
||||
|
||||
mailResp := doWithCookie(t, mw, http.MethodGet, "/mail?account=a&folder=INBOX&uid=1", nil, cookie)
|
||||
mailBody, _ := io.ReadAll(mailResp.Body)
|
||||
_ = mailResp.Body.Close()
|
||||
body := string(mailBody)
|
||||
if strings.Contains(body, "<script>") || strings.Contains(body, "<img") {
|
||||
t.Fatalf("the mail page carries a script or img element:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "<script>") {
|
||||
t.Fatalf("the mail's body is not shown as escaped text:\n%s", body)
|
||||
}
|
||||
if mailResp.Header.Get("Content-Security-Policy") != CSP {
|
||||
t.Fatalf("the mail page's CSP: %q", mailResp.Header.Get("Content-Security-Policy"))
|
||||
}
|
||||
|
||||
attResp := doWithCookie(t, mw, http.MethodGet, "/attachment?account=a&folder=INBOX&uid=1&key=att1", nil, cookie)
|
||||
attBody, _ := io.ReadAll(attResp.Body)
|
||||
_ = attResp.Body.Close()
|
||||
if attResp.Header.Get("Content-Type") != "application/octet-stream" {
|
||||
t.Fatalf("attachment Content-Type: %q", attResp.Header.Get("Content-Type"))
|
||||
}
|
||||
if attResp.Header.Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatalf("attachment X-Content-Type-Options: %q", attResp.Header.Get("X-Content-Type-Options"))
|
||||
}
|
||||
if !strings.HasPrefix(attResp.Header.Get("Content-Disposition"), "attachment;") {
|
||||
t.Fatalf("attachment Content-Disposition: %q", attResp.Header.Get("Content-Disposition"))
|
||||
}
|
||||
if attResp.Header.Get("Content-Security-Policy") != CSP {
|
||||
t.Fatalf("the attachment's CSP: %q", attResp.Header.Get("Content-Security-Policy"))
|
||||
}
|
||||
if string(attBody) != "hello world" {
|
||||
t.Fatalf("attachment body: %q", attBody)
|
||||
}
|
||||
|
||||
// Every page answered carries the CSP: the 404 for another host does too (issue 1283/AC2).
|
||||
otherHost := doWithCookie(t, mw, http.MethodGet, "/", nil, nil)
|
||||
_ = otherHost.Body.Close()
|
||||
if otherHost.Header.Get("Content-Security-Policy") != CSP {
|
||||
t.Fatalf("the sign-in redirect's own CSP: %q", otherHost.Header.Get("Content-Security-Policy"))
|
||||
}
|
||||
}
|
||||
@@ -703,6 +703,43 @@ func doWithCookie(t *testing.T, mw *startedMailWeb, method, path string, body io
|
||||
return resp
|
||||
}
|
||||
|
||||
// doForm is one form POST to mw's own endpoint, the session cookie attached when given — the shape issue
|
||||
// 1268's sign-in, CSRF and send scenarios share.
|
||||
func doForm(t *testing.T, mw *startedMailWeb, path string, form url.Values, cookie *http.Cookie) *http.Response {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodPost, "http://"+mw.internalHost+path, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Host = mw.internalHost
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
resp, err := mw.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// extractHiddenValue reads a hidden input's own value out of a rendered page's HTML, by its field name —
|
||||
// issue 1284's own form token, which is never answered by any tool, only ever rendered into a page
|
||||
// (write.go's FormTokenInput).
|
||||
func extractHiddenValue(body, field string) string {
|
||||
marker := `name="` + field + `" value="`
|
||||
i := strings.Index(body, marker)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := body[i+len(marker):]
|
||||
j := strings.Index(rest, `"`)
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
return rest[:j]
|
||||
}
|
||||
|
||||
// ---- issue 1285's own acceptance criteria -----------------------------------------------------------------
|
||||
|
||||
// 1285/AC1: initialize and tools/list complete, and a tools/call of mail-webclient.status answers.
|
||||
|
||||
Reference in New Issue
Block a user